lossik / mikrotik-ssh
MikroTik RouterOS client over SSH (console commands) and SFTP (file transfer) for lossik/device-communication
Requires
- php: ^8.2
- ext-iconv: *
- lossik/device-communication: ^3.0
- phpseclib/phpseclib: ^3.0.57
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
- squizlabs/php_codesniffer: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-02 13:32:53 UTC
README
PHP client for MikroTik RouterOS over SSH (console commands) and SFTP (file transfer), built on
lossik/device-communication and
phpseclib 3: SshTransport is the console implementation of its
Transport, and you talk to the router through its Client and Menu; FileTransfer moves files over SFTP.
3.0 is in development on this branch and not released. It has a new namespace (Lossik\Mikrotik\Ssh\) and is
built on lossik/device-communication 3.0 and phpseclib 3. For the released version see the 2.x branch and
composer require lossik/mikrotik-ssh:^2.1. This README describes 3.0; the changes against 2.x are in the
changelog, the upgrade guide (in Czech) is docs/upgrade-3.0.cs.md in the repository of
lossik/mikrotik-api. The links to lossik/device-communication and lossik/mikrotik-api point to their
repositories, whose published documentation is that of 2.x; until 3.0 is released, their 3.0 documentation is on
their 3.0 development branches.
The same Client code runs over the RouterOS API with
lossik/mikrotik-api, which is faster, can stream (listen) and has none of
the console differences listed under Rows compared with the API. Use SSH where the API
service is off or where you need SFTP.
Requirements
- PHP: see Supported PHP.
- Packages:
lossik/device-communication^3.0andphpseclib/phpseclib^3.0.57(3.0.57 fixes CVE-2026-84308). Composer installs one phpseclib per project: a project that needs phpseclib 2 elsewhere cannot install this version until that code moves to phpseclib 3. - Extensions:
ext-iconv, declared incomposer.json(used whencharsetis not UTF-8, see Charset). - RouterOS: see Supported RouterOS.
Supported RouterOS
The whole 7.x line is supported and tested at its boundaries; 6.x is tested from 6.34, the first CHR image. Other
versions go the same way without a guarantee: nothing is refused because of the version, and what the console does
not do ends loudly (TrapException with the console's own message, or ProtocolException), not with silently
wrong data. Below 7.17 the library reads the console in another way than from 7.17 on, see
Two console paths.
| RouterOS | |
|---|---|
| tested on every GitLab CI run (live suite against CHR) | 7.24.5, 6.49.18 |
| tested on code changes on GitHub (live suite against CHR) | every version in versions.txt, including text/json boundaries and the 6.49.23 upgrade |
| probed by hand, outside CI (console commands through measurement scripts, not this test suite) | 7.17, 7.17.1, 7.13, 7.13.5; 6.33 from the x86 ISO |
| not verified | every other version, among them 6.0–6.32, the 6.x releases between the measured ones (e.g. 6.43.1–6.49.17, 6.49.19–6.49.22), 7.0 (there is no CHR image of it) and the 7.x releases between the measured ones (above all 7.17.3–7.23.7 and 7.24.0–7.24.4) |
| not supported | 5.x: its console does not report a failed write (set of a missing item, a bad value, a duplicate add print nothing), so a failure could not be told from success. Use lossik/mikrotik-api for 5.x |
The live suite passes on all 12 versions of the CI matrix above.
- 6.49.23 is tested as an installation upgraded from 6.49.20. Fresh official CHR images 6.49.21 and newer lose their configuration in QEMU (a defect of the image, not of the library); 6.49.19–6.49.22 are not verified (a fresh 6.49.22 image was seen losing its configuration like the one of 6.49.23).
- Verified on CHR only, not on hardware: menus a CHR does not have (
ethersettings of real ports, wireless, switch chips) are not covered.
Installation
3.0 has not been released yet, so there is nothing to install. It needs PHP ^8.2, ext-iconv,
lossik/device-communication ^3.0 and phpseclib/phpseclib ^3.0.57 (see composer.json); installation instructions come with the release.
Quick start
use Lossik\DeviceCommunication\Client; use Lossik\DeviceCommunication\Query; use Lossik\Mikrotik\Ssh\SshOptions; use Lossik\Mikrotik\Ssh\SshTransport; $transport = SshTransport::connect('192.0.2.1', 'admin', 'secret', new SshOptions(responseTimeout: 120.0)); $client = new Client($transport); // rows are lists of string => string, the same as over the API; the proplist limits the returned properties $addresses = $client->menu('/ip/address')->print(Query::where('interface', 'ether1'), proplist: ['.id', 'address']); $row = $client->menu('/queue/tree')->first(Query::where('name', 'q1')); // ?array: null = nothing matches $id = $client->menu('/ip/firewall/address-list')->add(['list' => 'blocked', 'address' => '192.0.2.9']); // "*1B" // update() and delete() print the matching .id values first, then send one set / remove with numbers=*1,*2 // (only the first match with onlyFirst: true); they return the number of items and send nothing when none match $changed = $client->menu('/ip/firewall/address-list')->update(Query::where('list', 'blocked'), ['comment' => 'x']); $removed = $client->menu('/ip/firewall/address-list')->delete(Query::where('list', 'blocked'), onlyFirst: true); // any other verb of a menu; the ID of an item goes to the attribute .id $client->menu('/system/identity')->run('set', ['name' => 'R1']); $version = $client->version(); // e.g. "7.24.5 (stable)" $path = $transport->consolePath(); // 'json' or 'text', see below; null before the first command $transport->close();
The menu may be written /ip/address or ip address. The host may be an IPv6 address, with or without brackets.
The shared contract (Client, Menu, Query, Command, Result) is documented in
lossik/device-communication.
Options
SshTransport::connect(string $host, string $user, string $password, SshOptions $options = new SshOptions()), the
same for FileTransfer::connect(). All times are seconds, all arguments of SshOptions are named and optional:
| option | default | meaning |
|---|---|---|
port |
22 |
the SSH port |
connectTimeout |
10.0 |
one limit for opening the TCP connection and receiving the SSH banner together, then a separate limit of ceil(connectTimeout) that phpseclib gets for the key exchange and the login; running out is a TimeoutException with ->limit === 'connectTimeout' |
responseTimeout |
60.0 |
how long to wait for the output of each console command the transport runs (SshTransport) or for the reply to one SFTP request (FileTransfer); null = no limit. One execute() may run several console commands (the version query once per connection, the print, one retry without the proplist, one query per missing flag), each with the full limit, so it does not limit the whole execute(). The console answers within a fraction of a second, so the limit only guards against a device that went silent; raise it for commands that run long and for large tables on the text path |
charset |
'UTF-8' |
SshTransport only: the charset of the text on the device, see Charset |
The password is always sent, an empty one too (2.x used the SSH method none for an empty one). phpseclib itself
still tries none before the password, so an account with an empty password logs in with any password
(measured on 6.49.23): a wrong password is refused only for an account that has one.
Two console paths
The first command on a connection asks the router for its version (:put [/system resource get version], one more
console exec, 0.1–0.2 s measured on an emulated CHR), and the transport keeps the answer until close():
| path | RouterOS | how a print is read |
|---|---|---|
'json' |
7.17 and newer (also 7.17rc* and later pre-releases) |
:serialize to=json options=json.no-string-conversion. The option exists from 7.17 on (measured on 7.17, 7.17.1, 7.17.2 and 7.17rc1); 7.13–7.16 have :serialize, but without the option it turns a string that looks like a number into a number (007 into 7), so they take the text path |
'text' |
anything else, an unreadable answer included | a serializer written in the console language: every value is printed with its length in bytes, so a value may hold anything (;, =, quotes, newlines, --, END) |
$transport->consolePath() returns the path in use, null before the first command. An unreadable version picks
the text path: it runs on every measured version, 6.34 … 7.24.5, while the JSON path on an old router would only end
with a syntax error. A lost connection or a timeout during the version query is thrown as it is and closes the
transport.
On the text path:
- It is slower: a
printcosts 6–14 ms per row on an emulated CHR (1000 rows of 5 attributes: 6–14 s), 3.6 to 10.8 times the JSON path; on hardware it is faster, by how much is not measured. For large tables (/ip/firewall/connection) raiseresponseTimeout. - No command is wrapped: an error is the bare text of the console (
no such item (4),invalid value for argument address), and it differs between versions (7.24.5 adds a suffix like(/ip/address/set; line 1); an unknown attribute isexpected end of commandup to 7.17.2 andbad parameter …on 7.24.5). Match the reason, not the whole message. The exit status of the console is never used (it is 0 on errors on 6.43 and older and on 7.24.5). - The menu is written with spaces (
/ip address print): RouterOS 6 has no slash form. - The proplist is sent from 7.1 on; the console of 6.x has none, every column is printed and the row is reduced afterwards. The attributes the console prints only when the proplist names them are therefore missing on 6.x, see RouterOS 6.x.
Errors
Every runtime error implements the marker interface Lossik\DeviceCommunication\CommunicationException:
CommunicationException (interface)
|- ConnectionException closes the transport
| |- TimeoutException ->limit: 'connectTimeout' | 'responseTimeout'
| '- ProtocolException
|- AuthenticationException wrong user name or password; from connect() only
'- TrapException the console refused the command; the connection stays open
\UnsupportedOperationException not a CommunicationException (\LogicException)
| exception | when | after it |
|---|---|---|
ConnectionException |
the router is unreachable, the SSH handshake failed, the connection was lost, a call on a closed transport | closed |
TimeoutException |
->limit names the option that ran out |
closed |
ProtocolException |
the output of a print is not what its line prints (a broken field, no end mark), or the output is not valid in the configured charset |
closed |
AuthenticationException |
the router rejected the user name or password; no transport is returned | - |
TrapException |
the console printed an error: ->getMessage() is its text (converted from the charset); ->category is null and ->traps() is empty, the console has neither |
open |
UnsupportedOperationException |
a command or query the console line cannot express (see Queries), or listen; nothing was sent |
open |
\InvalidArgumentException |
a value of the command cannot be converted to charset; nothing was sent |
open |
After any ConnectionException (or subclass) every further call throws ConnectionException without I/O; create a
new transport. $transport->isOpen() tells whether it is still usable.
A change of several items is not atomic: set with several IDs (.id=*1,*FFFF, through run('set', …) or
update()) changes the items before the first unknown ID and then fails with TrapException (no such item); the
earlier items stay changed. The API does the same (measured on all 12 versions of the matrix).
Queries
Query conditions become a where of the console, the same on both paths. Every form below was compared with the
API by console probes on RouterOS 6.34 … 7.24.5 (each on at least 6.34, 7.17.2 and 7.24.5) and selects the same
rows, except less()/greater() with an IPv4 address and has()/hasNot() over an empty value (see below
the table):
| condition | console | note |
|---|---|---|
where(k, v) |
k="v" |
yes and no stay unquoted (k=yes); where(k, '') matches nothing, as on the API |
where(k, 'true'), where(k, 'false') |
k~"^true\$", k~"^false\$" |
exact on a flag (disabled) and on a string attribute (a comment "true"), also under not() |
has(k), hasNot(k) |
k~".", !(k~".") |
a non-empty value of the property, also of a flag and an IP address: has('disabled') matches every item that has the property, not the disabled ones (as ?disabled of the API); a property with an empty value counts as missing (see below the table) |
less(k, v), greater(k, v) |
k<"v", k>"v" |
only a non-negative integer (0 or digits without a leading zero) or an IPv4 address without a prefix; anything else (a time like 1h, an IPv6 address, a prefix, a negative or decimal number, a string) is an UnsupportedOperationException before anything is sent, because the console does not compare it like the API (a string: nothing on 6.x, every row on 7.x) |
and(), or(), not() |
a and b, (a or b), !(…) |
- An IPv4 address in
less()/greater()is compared as an address on every version (the console treats the bare, the quoted and the[:toip]form alike), but the API compares it as text on RouterOS 6.42.12 … 7.25rc3, also over an IP-typed attribute (6.34 compared addresses).less('address', '10.97.3.10')over the console matches10.97.3.1…10.97.3.9, over the API only10.97.3.1: wherever text order and address order differ, this library andlossik/mikrotik-apireturn different rows. An integer is compared the same on both. has()needs a non-empty value,hasNot()matches a missing or an empty one. The consolek~"."needs a character, while the API?kis the existence of the property, also with an empty value. The emptyaddressof/ip/serviceon a fresh router: the APIhas('address')matches those services, this library matches them withhasNot('address')and none of them withhas('address')(measured on 6.49.18 and 7.24.5, checked by the live suite on every version).- An integer compared with a string attribute (
less('comment', '5')) is sent, but the API, the console of 6.x and the console of 7.x give three different results. The library does not know the type of an attribute, so it cannot refuse it: compare numbers only with numeric attributes. - There is no prefix or regular expression condition (2.x: the
~prefix); filter in PHP (array_filter()withstr_starts_with()orpreg_match()). - Menu levels must consist of
A-Z a-z 0-9 _ -; verbs, attributes, query keys and proplist entries may also hold.(.id). Anything else is anUnsupportedOperationException. - A value is sent as a quoted console string:
\,",$,?and line breaks are escaped, and every byte outside printable ASCII is written as\HH(the console silently drops such a byte written as it is), so UTF-8 text in a value or in awherearrives whole.
Rows compared with the API
The rows are normalised to what the API returns: every value is a string (true, 1500), a list is joined by
commas (10.0.0.0/24,10.0.1.0/24, an empty one ''), an empty comment is left out, and the ID add() returns is
in upper case (*1B; the console of 7.17.x and older prints *1b). The keys of a row do not come in the order of
the API (on the text path they are alphabetical, a filled-in flag comes last); do not rely on the order.
What stays different from the API (measured on 7.23.8 and 7.24.5):
- another unit:
bucket-sizeandburst-timeof/queue/tree(console100,00:00:00; API0.1,0s),life-timeof/ipv6/dhcp-server/binding(a date like1970-01-08 00:00:00; API1w); - not printed by the console at all: the counters (
bytes,packets,rate,dropped,queued-bytes… of queues and firewall rules) and thepasswordof/ppp/secret; - on 6.x: the attributes printed only with a proplist (see RouterOS 6.x);
/ip/dnsand/system/resource: times in another format (console00:00:02,1w00:00:00; API2s,1w), e.g.cache-max-ttl,query-server-timeout,uptime.
Flags (disabled, dynamic, invalid, running): RouterOS 7.23.8 and 7.24.5 print them in a print, 7.17.2
and 6.x do not. When the proplist names a flag that is missing in every returned row, the transport asks the
console for the IDs of the items with that flag true (one more exec per such flag) and fills in 'true' or
'false'. A flag the menu does not have (running of /ip/firewall/address-list) comes out as 'false', where the
API has no such key.
A proplist the console refuses is retried once without it, and the transport reduces the columns itself, as on 6.x. Two refusals trigger it, on both paths:
- a column the menu does not have: from 7.1 on the console refuses the whole
print("input does not match any value of value-name"). Such a column is then absent from the row, a missing flag is'false'(above), on every version; - a proplist on a singleton menu (
/system/resource,/system/identity,/ip/dns, …): RouterOS 7.1 … 7.17.2 refuse it while parsing the line ("expected end of command"). This costs one more exec for every suchprinton those versions, including everyClient::version()(it prints/system/resourcewith the proplistversion). 7.23.8 and newer accept the line and the JSON path falls back inside it, without another exec.
Any other refusal is a TrapException without a retry. Singleton menus give one row, as on the API, on every
version of the matrix.
A value that is a map (a list with named keys instead of a plain list) would lose its keys on the text path, where a list is joined by commas; the JSON path keeps it as JSON text. No measured menu has such a value, and the text path does not detect it.
RouterOS 6.x: attributes the console does not print
The console of 6.x has no proplist, and its print as-value leaves out attributes that the API returns, also when
you name them in the proplist. Measured in the menus of the live suite (6.34 … 6.49.23):
| menu | missing on 6.x |
|---|---|
/ip/address |
actual-interface |
/ip/arp |
DHCP, complete, published |
/ppp/secret |
ipv6-routes, last-logged-out, limit-bytes-in, limit-bytes-out, routes |
/ip/dhcp-server/lease |
address-lists, blocked, dhcp-option, last-seen, radius |
/ipv6/dhcp-server/binding |
active-server, address-lists, client-address, dhcp-option, iaid, last-seen, prefix-pool, radius |
Not only state is missing: values you wrote yourself are among them (the address-lists of a lease, the
limit-bytes-* of a PPP secret). Other menus may have more. On 6.x read such attributes through the API
(lossik/mikrotik-api). From 7.1 on the transport sends the proplist and the rows match the API.
Files (SFTP)
use Lossik\Mikrotik\Ssh\FileTransfer; $files = FileTransfer::connect('192.0.2.1', 'admin', 'secret'); $files->upload('/local/backup.rsc', 'backup.rsc'); $content = $files->download('backup.rsc'); // the content as a string $written = $files->download('backup.rsc', '/local/copy.rsc'); // true; written to the local file $files->close();
A request the router refuses (no such file, no permission) is a TrapException and the session stays open; a local
file that is not readable is an \InvalidArgumentException before anything is sent; a lost connection or a
responseTimeout is a ConnectionException / TimeoutException and closes the session. File names and contents
are bytes, charset does not apply.
SFTP works the same on every version of the matrix, 6.34 … 7.25rc3. On 7.16.2 and 7.17.2 a file just uploaded
appears in the /file menu (API, console) only about 100–500 ms after the transfer has closed; SFTP itself sees it
at once. Wait or retry before you look for a fresh upload in /file.
Charset
By default (charset: 'UTF-8') values and output pass unchanged. Any other ASCII-compatible single-byte charset
(e.g. 'windows-1250', the default of 2.x) converts every value of a command to it, and the output back to UTF-8,
with iconv, like ApiOptions::$charset of lossik/mikrotik-api. A value the charset cannot represent is an
\InvalidArgumentException before anything is sent; output that is not valid in the charset is a
ProtocolException and closes the transport. On the text path the console returns the stored bytes unchanged, so
cp1250 text written by 2.x is read back with charset: 'windows-1250'.
Known limits
- One command at a time per connection: every command opens its own exec channel on the one SSH session; do not share a transport between concurrent users.
- No streaming:
listenthrowsUnsupportedOperationException(uselossik/mikrotik-api). - No raw console line: the transport runs a
Commandonly (a menu, a verb, attributes, a query, a proplist). - The text path is slower; raise
responseTimeoutfor large tables (see Two console paths). - The differences from the API stay; on 6.x attributes the API returns are missing, some of them written by the user (see RouterOS 6.x).
- A proplist on a singleton menu costs one more exec on 7.1 … 7.17.2, also every
Client::version(). - A map value would lose its keys on the text path (not seen in any measured menu).
setwith several IDs is not atomic (as on the API, see Errors).less()/greater()accept only a non-negative integer or an IPv4 address, and an IPv4 address is compared as an address, where the API compares it as text (see Queries).has()means a non-empty value,hasNot()a missing or an empty one; over the APIhas()is the existence of the property, also with an empty value (see Queries).- An account with an empty password logs in with any password (phpseclib tries
nonefirst, see Options). - RouterOS 5.x is not supported over SSH.
Tests
composer test runs the unit suite without a router (a fake phpseclib session and console outputs recorded on
CHR); composer ci adds phpcs and PHPStan. The group docker runs SshTransport and FileTransfer against
OpenSSH started inside a php:8.2.35-cli container (from the repository root: bash tools/ci.sh --deps-only && bash tools/sshd/start.sh && vendor/bin/phpunit --group docker); the CI job docker runs it on every pipeline.
The live suite runs against a real CHR in QEMU inside one php container, with Docker as the only requirement: see
tools/chr/README.md for the local run. In CI it runs on every pipeline for 7.24.5 and 6.49.18
and on GitHub code changes for the whole matrix, one version per job. The column ssh-path of
tools/chr/versions.txt says which path a version takes, and the tests of the other path (group live-json or
live-text) are left out. On CHR 6.x the harness enables the ipv6 package (installed but disabled on those
images), which costs one extra reboot per job; 6.49.23 is booted from the 6.49.20 image and upgraded.
The live tests do not ask the RouterOS version, except in two places, both a user decision of 26. 10. 2026 about a
measured difference of RouterOS documented above: MenuLiveTest leaves out on 6.x (below 7.1) the attributes the
6.x console does not print, and QueryLiveTest expects the API to compare an IPv4 address in less()/greater()
as an address on 6.34 (below 6.42) and as text on every other version. Pinned by version, a change of either on a
version of the matrix turns the suite red.
Supported PHP
Only patched PHP releases are supported: 8.2.35+, 8.3.36+, 8.4.27+ and 8.5.12+ (CVE-2026-109618 and
CVE-2026-109619, "PHP2Shell"). composer.json requires "php": "^8.2"; only the CI of this project enforces the
patched minima.
License
MIT, see LICENSE.