-
Notifications
You must be signed in to change notification settings - Fork 98
Command translation on non API transports
Every transport in tik4net is used through the same ITikConnection /
ITikCommand surface. You build a command against an API-style path
(/ip/address/print) with API field names (src-address) and call one of the ExecuteXxx methods —
exactly as you would on the binary API.
On the binary Api/ApiSsl transport those calls go straight onto the wire as the native
length-prefixed sentence protocol. On every other transport (Telnet, SSH, MAC-Telnet, WinBox CLI,
WinBox Native, REST) the same call has to be translated into whatever that transport actually speaks
— a CLI line, an M2 message, or an HTTP request — and the textual/binary reply parsed back into the
!re/!done sentence model the O/R mapper expects.
This page explains what gets translated, into what, and in which layer.
TL;DR —
ExecuteXxx→ a verb (add/set/…) → one of three transport hooks (RunPrint/RunAdd/RunNonQuery) → a per-transport builder turns the path + parameters into the wire form → a per-transport parser turns the reply back into sentences. Your code never sees the difference; only the capabilities differ (no streaming / tagging / raw sentences off the binary API).
your code: conn.CreateCommand("/ip/address/print").ExecuteList()
│
┌───────────────────────────────┼─────────────────────────────────────────────┐
│ ITikCommand (TikGenericCommand) │
│ • ExecuteList / ExecuteScalar / ExecuteSingleRow → read → RunPrint │
│ • ExecuteNonQuery → write → RunNonQuery │
│ • (add verb / ExecuteScalar on add) → insert → RunAdd │
│ • ExecuteAsync / LoadAsync / LoadListenAsync → monitor → RunMonitorAsync │
│ • verb is the LAST path segment (print/add/set/remove/move/…) │
└───────────────────────────────┼─────────────────────────────────────────────┘
│ (transport-neutral: path + ITikCommandParameter list)
┌───────────────────────────────┼─────────────────────────────────────────────┐
│ TikCommandConnectionBase — common plumbing for all non-API transports │
│ abstract RunPrint(descriptor) : IList<TikRecordSentence> │
│ abstract RunAdd(descriptor) : string (new .id) │
│ abstract RunNonQuery(descriptor) │
└───────────────────────────────┼─────────────────────────────────────────────┘
┌────────────────────┼─────────────────────────┐
▼ ▼ ▼
┌────────────────────┐ ┌────────────────────┐ ┌───────────────────────────┐
│ CliConnectionBase │ │ WinboxNative │ │ RestConnection │
│ Telnet / SSH / │ │ Connection │ │ (own builder/parser) │
│ MAC-Telnet / │ │ │ │ │
│ WinBox CLI(+MAC) │ │ M2 getall/set/… │ │ GET/POST/PATCH/DELETE │
│ │ │ │ │ │
│ CliCommandBuilder │ │ WinboxHandlerMap │ │ RestRequestBuilder │
│ → CLI text │ │ path → [maj,min] │ │ → URL + JSON │
│ CliOutputParser │ │ WinboxFieldResolver│ │ System.Text.Json parse │
│ text → sentences │ │ name ↔ M2 key │ │ │
└────────────────────┘ └────────────────────┘ └───────────────────────────┘
│ │ │
terminal / PTY encrypted M2 (8291/20561) HTTP(S)
The transport-neutral half (top two boxes) is identical for everyone: command parsing, verb
detection, parameter normalization, the Run* contract, diagnostics (OnReadRow/OnWriteRow) and Safe
Mode plumbing all live in TikGenericCommand + TikCommandConnectionBase. Only the bottom box —
build + parse — is transport-specific.
ITikCommand is implemented once for all non-API transports by TikGenericCommand. It looks at the
verb (the last segment of the command path) and dispatches to one of the three hooks:
ExecuteXxx |
Verb | Hook | Returns |
|---|---|---|---|
ExecuteList() / ExecuteList(proplist)
|
print (or none) |
RunPrint |
all matching rows |
ExecuteSingleRow() / …OrDefault()
|
print |
RunPrint |
exactly one / null |
ExecuteScalar() / …OrDefault()
|
print |
RunPrint |
one field of one row |
ExecuteScalar() on an …/add path |
add |
RunAdd |
the new .id
|
ExecuteNonQuery() |
add |
RunAdd |
— |
ExecuteNonQuery() |
set/remove/unset/move/enable/disable
|
RunNonQuery |
— |
ExecuteNonQuery() |
action verb (run, reboot, …) |
RunNonQuery |
— |
ExecuteAsync / LoadAsync / LoadListenAsync
|
print/listen/monitor |
RunMonitorAsync (if supported)
|
rows via callback |
ExecuteListWithDuration (streaming) |
— | — |
throws …CapabilityNotSupportedException
|
Two normalizations happen before the hook is called, identical on every transport:
-
Multi-line command — a command text like
"/interface/print\n?type=ether"is split into the path plus parsed?(Filter)/=(NameValue) parameters (NormalizeMultilineCommand). -
Read parameter coercion — for reads, bare
Defaultparameters are promoted to Filter (?name=value) form, because on a read they mean "filter by", not "set" (ResolveParamsForRead).
proplist (field trimming) is ignored off the binary API — the CLI/native/REST reads always return
the full field set, and the O/R mapper just picks the fields it needs.
All five CLI transports share CliConnectionBase; they differ only in the socket underneath. The
translation is done by CliCommandBuilder (build) and CliOutputParser (parse).
API path segments become CLI words — the slashes turn into spaces, the last segment is the verb:
/ip/address/print → /ip address print
/interface/ethernet/monitor → /interface ethernet monitor
A read is wrapped in :put [ … as-value ] so RouterOS materialises a machine-readable line (a bare
print as-value prints nothing to a terminal):
ExecuteList() on /ip/address with ?disabled=false
→ :put [/ip address print as-value where disabled=false]
Reply (one line, records concatenated, each starting at .id=):
.id=*1;address=192.168.88.1/24;network=192.168.88.0;interface=ether1;disabled=false;.id=*2;address=…
CliOutputParser.ParseAsValue splits this back into TikRecordSentence objects (one per .id=), which
are returned to the O/R mapper as !re sentences.
Where-clause translation (Filter parameters → RouterOS where):
| Parameter (API filter) | CLI where
|
|---|---|
?chain=input |
where chain=input |
?chain=!input (negation) |
where chain!=input |
?>count=100 |
where count>100 |
?<count=100 |
where count<100 |
?~comment=eth (regex) |
where comment~eth |
| multiple filters | joined with &&
|
Values that contain characters the CLI parser treats as operators (/ in 192.168.1.1/24, : in MACs)
are quoted in the where clause (QuoteForWhere); name=value arguments with spaces/;/# are quoted
for add/set (QuoteIfNeeded).
Live counters — entities flagged with .cli-stats are read with two queries
(print detail as-value for config + print stats as-value for byte/packet counters) and merged by
.id, because RouterOS exposes those columns only under the stats modifier.
/ip/address/add =address=10.0.0.1/24 =interface=ether1
→ :put [/ip address add address=10.0.0.1/24 interface=ether1]
The :put [ … ] wrapper makes RouterOS echo the new record's .id (*N), which is returned as the
scalar result.
These select the target by .id with a [find …] expression:
/ip/address/set =.id=*1 =comment=lan
→ /ip address set [find .id=*1] comment=lan
/ip/address/remove =.id=*1
→ /ip address remove [find .id=*1]
A convenience: if you pass a name instead of a *N handle, the CLI builder widens the selector to
[find .id=X or name=X], so set .id=ether1 … resolves a named record (the binary API/native accept a
name directly; this bridges the CLI gap).
-
Core (
CliCommandBuilder/CliOutputParser) is transport-agnostic: it only produces/consumes clean text (data lineskey=value;…). -
The transport (Telnet/SSH/WinBox CLI/MAC-Telnet) is responsible for the terminal mechanics it sees
before the core: stripping ANSI/VT100 escape sequences (
VtStripper), trimming command echo and the[user@router] >prompt, and (on a paged PTY) suppressing paging. The core never sees a control byte. -
Errors — the reply text is scanned by
CliErrorParser, which mapsno such item,already have such…,no such command,failure:etc. to the same tik4net exception types the binary API raises (TikNoSuchItemException,TikAlreadyHaveSuchItemException, …).
A terminal has no server push, so ExecuteAsync/LoadAsync/LoadListenAsync are emulated by
polling (that's why CLI reports Listen but not Streaming):
-
/path/print(LoadAsync) — runs the read once on a worker, emits rows, completes. -
/path/listen— re-reads the table on a timer and diffs snapshots by.id; an added/changed row fires your callback, a vanished.idfires a synthetic delete. - a monitor verb (
monitor-traffic,profile, ping, …) — re-issues a one-shot:put [… once as-value]every ~500 ms. Interactive-only verbs (torch) are rejected with guidance to use the binary API.
WinBox Native skips the terminal entirely and issues the binary M2 key–value messages WinBox itself uses. The translation problem is different: the M2 protocol addresses things by number, not text. See WinBox-Native-connection for the full mapping story; in brief:
| Translation | From | To | Source |
|---|---|---|---|
| Path → handler | /ip/firewall/filter |
[20, 3] |
live .jg menu tree + shipped aliases + PathOverride
|
| Field name ↔ key | src-address |
0x… key |
live .jg field table + seeds + aliases + FieldOverride
|
| Verb → M2 command |
print/set/add/remove/move
|
getall/set/add/remove/move cmd |
hard-coded protocol constants |
| Value encode/decode |
192.168.1.1/24, AA:BB:…, enum |
u32 addr+mask, 6 raw bytes, numeric index/ref-id |
.jg field type (WinboxFieldResolver) |
-
Reads (
RunPrint) call M2getall(orget-singletonforitemwindows), decode each record's numeric keys back to API field names, then apply any?name=valueFilter parameters in-memory (RouterOS-side filtering is not used) via the same postfix query stack the CLI async path uses. -
Writes (
RunNonQuery/RunAdd) resolve the target.id(a*HEXhandle, or a friendly name resolved viagetall) and encode each field by its.jgtype. - Errors — the M2 error code + router text are mapped to the same exception types as the API/CLI transports.
-
Async/monitors —
.jgquerywindows (torch, profile, monitor-traffic, ping) are polled start → poll → cancel on a worker;listenis poll+diff, exactly like CLI.
The handler/field numbers are never hard-coded — they are read live from the router's version-matched
.jg catalog and cached. Only the stable English text bridge (API name ↔ GUI label normalizer +
alias dictionary) ships in the library.
The Rest/RestSsl transport (RestConnection, RouterOS 7.1+) maps the verb to an HTTP method and the
path to a URL, with System.Text.Json for the body (RestRequestBuilder):
| Verb | HTTP |
|---|---|
print |
GET /rest/ip/address (filters → query string) |
add |
POST /rest/ip/address (JSON body) |
set |
PATCH /rest/ip/address/*1 |
remove |
DELETE /rest/ip/address/*1 |
REST is stateless request/reply: it reports Crud only — no Listen, no Streaming, no Safe Mode.
-
Same code, any transport.
LoadAll<T>(),Save,Delete,ExecuteList,ExecuteScalar,ExecuteNonQuerybehave identically; only performance and the feature set differ. -
Check capabilities, not transport type. Streaming monitors, tagged async and raw sentences exist
only on the binary API. Guard with
connection.Supports(TikConnectionCapability.Streaming)(see Connection types & capabilities). -
Errors are uniform. Every transport raises the same
TikNoSuchItemException/TikAlreadyHaveSuchItemException/TikNoSuchCommandException/ … types, socatchblocks port across transports. -
Diagnostics show the wire form. Attach
OnWriteRow/OnReadRow(or setDebugEnabled) to see the translated CLI line / M2 message describe / HTTP request that actually went out — handy when a mapping is off. See Communication debugging & testing.
Two features build directly on the translation model above. Both ship on the 4.x branch.
For commands the structured mapping doesn't cover (or one-offs you'd rather type verbatim), the
capability-gated factory CreateRawCommand(raw) sends a raw payload in the transport's own native
dialect — bypassing the builder/mapper — while keeping the normal ITikCommand / ITikReSentence model.
Raw-ness is a property of the command (an internal command kind), not a connection mode:
// payload format depends on the transport (see table)
ITikCommand cmd = conn.CreateRawCommand("/interface print stats as-value");
foreach (ITikReSentence re in cmd.ExecuteList()) // send → parse → sentences
Console.WriteLine(re.GetResponseField("name"));
string export = conn.CreateRawCommand("/export").ExecuteScalar(); // free-form → raw text
// wrapAsValue:true wraps a bare CLI print in ':put [ … as-value]' so it yields parseable rows
foreach (var re in conn.CreateRawCommand("/ip address print", wrapAsValue: true).ExecuteList()) { /* … */ }| Transport | Raw payload |
ExecuteList returns |
Supported |
|---|---|---|---|
Api / ApiSsl
|
API sentence, \n-separated words (/interface/print\n?type=ether) |
real !re sentences (lossless) |
✅ |
CLI family (Telnet/Ssh/MacTelnet/WinboxCli/WinboxCliMac) |
verbatim CLI line |
as-value parsed to sentences |
✅ |
Rest / RestSsl
|
— (raw JSON possible but low value; verb + path still needed) | — | ❌ throws CapabilityNotSupportedException
|
WinboxNative / WinboxNativeMac
|
— (M2 is handler + cmd + numeric keys, not a string) | — | ❌ throws CapabilityNotSupportedException
|
The structured ExecuteXxx path stays the default; this is an explicit escape hatch that reuses the
existing command/sentence abstraction (no parallel string-only API). It is gated by the RawCommand
capability, so REST and WinBox Native — where "raw" would mean a numeric M2 message, not a string —
don't declare it and fail closed; use the WinBox CLI transport for raw access over that same encrypted
channel. Parameters added to a raw command are ignored — the whole payload lives in the command text.
(Note: the binary API already exposes a raw path via CallCommandSync; the factory is just the unified
ITikCommand-shaped front for it, so on the API a raw command needs no flag.)
WinBox Native derives API field names from the GUI labels in the .jg (lower-case, spaces/_ → -,
abbreviation dots dropped). With UseGuiNames you can also address paths/fields by the GUI name you see
in WinBox — a label copied straight from the GUI resolves without knowing the exact API spelling:
var conn = (WinboxNativeConnection)ConnectionFactory.CreateConnection(TikConnectionType.WinboxNative);
conn.UseGuiNames = true; // opt-in; default is strict API-name resolution
conn.Open(HOST, USER, PASS);
conn.CreateCommandAndParameters("/IP/Firewall/Filter/set",
".id", id, "Src._Address", "10.0.0.0/24") // GUI-style path + field names
.ExecuteNonQuery();The input name is matched verbatim first; only if that misses is the existing label normalizer applied
(so spaces, underscores, abbreviation dots and case all fold to the API form). It is opt-in per
connection so it never clashes with strict resolution; session FieldOverride/PathOverride are checked
first and always win; and decoded output always uses canonical API names regardless of the flag.
Because the resolver dictionaries are already case-insensitive, what UseGuiNames really adds is folding
the WinBox separators (space / _ → -, . dropped) on the input side.
Both features are listed on the 4.x roadmap.
- Connection types & capabilities — the capability matrix and fail-closed model
- WinBox-Native-connection — the API↔M2 mapping in depth
- SSH-connection · MAC-Telnet-connection · WinBox-CLI-connection
- CRUD examples for all APIs
- Communication debugging & testing — see the translated wire form