Skip to content

Instantly share code, notes, and snippets.

@gregsadetsky
Last active July 16, 2026 16:27
Show Gist options
  • Select an option

  • Save gregsadetsky/eab0be331cb99342dd4be6bab7353f13 to your computer and use it in GitHub Desktop.

Select an option

Save gregsadetsky/eab0be331cb99342dd4be6bab7353f13 to your computer and use it in GitHub Desktop.
#!/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