Last active
July 16, 2026 16:27
-
-
Save gregsadetsky/eab0be331cb99342dd4be6bab7353f13 to your computer and use it in GitHub Desktop.
MAC-telnet CLI for LLMs -- see https://blog.greg.technology/2026/07/14/llm-networking-with-mikrotik.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S uv run --script | |
| # /// script | |
| # requires-python = ">=3.9" | |
| # dependencies = ["pyte"] | |
| # /// | |
| """ | |
| mtrun — batch-run RouterOS console commands over MAC-Telnet (Layer 2), for LLM/agent use. | |
| Design goal: turn mactelnet's interactive console into a clean, stateless | |
| request/response call. One invocation = log in once, run a batch of commands, | |
| return the output as plain text, exit. No daemon, no persistent session. | |
| Why a terminal emulator (pyte)? | |
| RouterOS' console does not offer "run command, return output". It drives a | |
| VT-style terminal, and on connect it interrogates that terminal: height, | |
| width (ESC[9999C), scroll regions, index behaviour, even UTF-8 character | |
| widths -- each time asking "where is your cursor now?" (ESC[6n). A pty is | |
| NOT a terminal: it never answers, so RouterOS blocks ~10s before falling | |
| back to defaults. pyte parses those sequences, tracks the real cursor and | |
| lets us answer truthfully, which removes the stall (10s -> ~0.3s) and gives | |
| us the rendered screen instead of a soup of redraw escape codes. | |
| Usage: | |
| MT_PASSWORD=... ./mtrun.py <MAC|identity> -c '/system resource print' -c '/interface print' | |
| # with uv, pyte is fetched automatically: | |
| uv run mtrun.py <MAC|identity> -c '/system identity print' | |
| # otherwise: pip install pyte | |
| Password comes from $MT_PASSWORD (never argv, so it cannot leak via ps/history). | |
| User defaults to 'admin' (override with -u). | |
| """ | |
| import argparse | |
| import fcntl | |
| import os | |
| import pty | |
| import re | |
| import select | |
| import shutil | |
| import struct | |
| import sys | |
| import termios | |
| import time | |
| try: | |
| import pyte | |
| except ImportError: | |
| sys.exit("mtrun needs pyte: pip install pyte (or run via: uv run mtrun.py ...)") | |
| # A tall screen: RouterOS paginates based on the height we report, so claiming a | |
| # long screen means output arrives in one piece instead of "-- [Q quit|D dump]". | |
| ROWS, COLS = 2000, 200 | |
| PROMPT = re.compile(r"\[[^\]]*\]\s*>\s*$") | |
| CPR = re.compile(r"\x1b\[6n") | |
| class Console: | |
| """mactelnet running under a pty, with a real terminal emulator behind it.""" | |
| def __init__(self, binary, target, user, timeout): | |
| self.timeout = timeout | |
| self.screen = pyte.Screen(COLS, ROWS) | |
| self.stream = pyte.Stream(self.screen) | |
| master, slave = pty.openpty() | |
| try: | |
| fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0)) | |
| except OSError: | |
| pass | |
| self.pid = os.fork() | |
| if self.pid == 0: # child: become mactelnet on the pty | |
| os.close(master) | |
| os.setsid() | |
| os.dup2(slave, 0) | |
| os.dup2(slave, 1) | |
| os.dup2(slave, 2) | |
| if slave > 2: | |
| os.close(slave) | |
| try: | |
| fcntl.ioctl(0, termios.TIOCSCTTY, 0) | |
| except OSError: | |
| pass | |
| # No -p: the password is typed at the prompt, never placed in argv. | |
| os.execvp(binary, [binary, "-u", user, target]) | |
| os._exit(127) | |
| os.close(slave) | |
| self.fd = master | |
| def send(self, text): | |
| os.write(self.fd, text.encode()) | |
| def row_text(self, y): | |
| """Text of one screen row. (screen.display re-renders every row, which is | |
| far too slow to call in a poll loop on a tall screen.)""" | |
| row = self.screen.buffer.get(y) | |
| if not row: | |
| return "" | |
| return "".join(row[x].data for x in sorted(row)).rstrip() | |
| def line_at_cursor(self): | |
| return self.row_text(self.screen.cursor.y) | |
| def pump(self, until, deadline): | |
| """Feed output to the emulator until `until()` is true. Answers RouterOS' | |
| terminal probes with the emulator's real cursor position.""" | |
| while time.time() < deadline: | |
| r, _, _ = select.select([self.fd], [], [], 0.1) | |
| if r: | |
| try: | |
| data = os.read(self.fd, 65536) | |
| except OSError: | |
| data = b"" | |
| if not data: | |
| break | |
| text = data.decode("utf-8", "replace") | |
| self.stream.feed(text) | |
| for _ in CPR.findall(text): | |
| # This is the whole trick: answer with the true cursor. | |
| self.send("\x1b[%d;%dR" % (self.screen.cursor.y + 1, self.screen.cursor.x + 1)) | |
| if until(): | |
| return True | |
| return False | |
| def close(self): | |
| try: | |
| self.send("/quit\r") # leave cleanly: killed sessions wedge the mac-server | |
| time.sleep(0.2) | |
| except OSError: | |
| pass | |
| try: | |
| os.close(self.fd) | |
| except OSError: | |
| pass | |
| try: | |
| os.kill(self.pid, 9) | |
| os.waitpid(self.pid, 0) | |
| except OSError: | |
| pass | |
| def main(): | |
| ap = argparse.ArgumentParser(description="Batch RouterOS commands over MAC-Telnet.") | |
| ap.add_argument("target", help="device MAC address or MNDP identity") | |
| ap.add_argument("-u", "--user", default=os.environ.get("MT_USER", "admin")) | |
| ap.add_argument("-c", "--command", action="append", default=[], dest="commands", | |
| help="command to run (repeatable)") | |
| ap.add_argument("--bin", default=shutil.which("mactelnet") or "mactelnet") | |
| ap.add_argument("--timeout", type=float, default=20.0) | |
| args = ap.parse_args() | |
| if not args.commands: | |
| ap.error("give at least one -c command") | |
| password = os.environ.get("MT_PASSWORD") | |
| if password is None: | |
| sys.exit("set MT_PASSWORD in the environment") | |
| con = Console(args.bin, args.target, args.user, args.timeout) | |
| deadline = time.time() + args.timeout | |
| try: | |
| # Log in: answer the password prompt, then wait for the console prompt. | |
| if not con.pump(lambda: con.line_at_cursor().endswith("Password:"), deadline): | |
| sys.exit("never asked for a password (wrong MAC/identity, or mac-server off?)") | |
| con.send(password + "\r") | |
| if not con.pump(lambda: PROMPT.search(con.line_at_cursor() + " "), deadline): | |
| sys.exit("login failed or console not ready") | |
| out = [] | |
| for cmd in args.commands: | |
| con.send(cmd + "\r") | |
| # The console lives at the bottom of a scrolling screen, so the cursor | |
| # row never moves, and the echo is too fleeting to catch (echo, output | |
| # and the next prompt can all arrive together). Test durable state | |
| # instead: the command has finished once the cursor sits on a bare | |
| # prompt and the echoed command is visible somewhere above it. | |
| def echo_row(): | |
| for i in range(con.screen.cursor.y - 1, max(-1, con.screen.cursor.y - 400), -1): | |
| if con.row_text(i).endswith("> " + cmd): | |
| return i | |
| return None | |
| def done(): | |
| return PROMPT.search(con.line_at_cursor() + " ") and echo_row() is not None | |
| if not con.pump(done, time.time() + args.timeout): | |
| out.append("$ %s\n<timed out>" % cmd) | |
| break | |
| # Output is what the terminal drew between the echo and the new prompt. | |
| start, end = echo_row(), con.screen.cursor.y | |
| body = [con.row_text(i) for i in range(start + 1, end)] if start is not None else [] | |
| while body and not body[-1]: | |
| body.pop() | |
| out.append("$ %s\n%s" % (cmd, "\n".join(body))) | |
| print("\n\n".join(out)) | |
| finally: | |
| con.close() | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment