Flex Engineers
4 min read

HotPot: A Web Admin Panel for MikroTik Hotspot Management

Node.jsExpressRxJSNetworkingAdmin DashboardSystem Integration

The problem: RouterOS has no web UI for hotspot users

MikroTik routers run hotspot services that ISPs and venue owners use to sell timed or profile-based internet access — a common setup for small WISPs, cafes, and apartment buildings across markets where MikroTik hardware dominates. But managing hotspot users (adding one, checking uptime, changing a package, disabling a freeloader) means either dropping into Winbox, a native Windows desktop tool, or hand-typing commands over SSH into RouterOS's own scripting language.

Neither is something you hand to a non-technical front-desk operator, and neither is scriptable into a browser-based operations workflow. HotPot is a small Express app our team built to put a normal HTML admin panel in front of a MikroTik router's hotspot user database, talking to the router directly over its binary API instead of screen-scraping Winbox or shelling out to SSH.

Talking to RouterOS: the API is a stream, not a REST endpoint

The core technical constraint of this project is that RouterOS doesn't expose HTTP or REST. It exposes a proprietary binary protocol on port 8728/8729 built around "sentences" — words like /ip/hotspot/user/print sent down a persistent TCP connection, with replies arriving asynchronously as a stream of packets rather than a single request/response pair. Our team used the mikronode package, which wraps that protocol in RxJS observables and channel-based promises, and built a thin service layer (controllers/api.js) on top of it that the rest of the app treats like a normal data-access module.

const MikroNode = require('mikronode');
const device = new MikroNode('10.10.10.147');

device.connect()
  .then(([login]) => login('username', 'password'))
  .then((conn) => {
    exports.getUserList = (throwResult) => {
      const listenChannel = conn.openChannel('userList');
      listenChannel
        .write('/ip/hotspot/user/print')
        .then((result) => {
          const packet = MikroNode.resultsToObj(result.data);
          throwResult(packet);
          listenChannel.closeOnDone(true);
        })
        .catch((error) => console.log('Listen channel rejection:', error));
    };
    // ...getUserInformation, postAddUser, getProfileList, postEditUser, postRemoveUser
  });

Two decisions in that snippet matter more than they look:

  • One long-lived connection, many short-lived channels. MikroNode multiplexes independent "channels" over a single TCP session, each identified by a tag (userList, userdetails, editUser...). Instead of reconnecting per request, every controller action opens a fresh channel on the existing connection and tears it down with closeOnDone(true) once its command finishes. That keeps request latency to a single router round-trip instead of a login handshake on every click.
  • Callback bridging, deliberately. mikronode's native idiom is RxJS streams (.data.subscribe(...)) for long-running listeners and promises for one-shot commands. Since the admin panel only needs request/response semantics — "fetch the user list," "add this user" — the team standardized every exported function on a (throwResult, ...args) => void callback shape. That let the Express controllers stay ignorant of RxJS entirely and just do API.getUserList(callback), which keeps the learning curve low for anyone maintaining the route layer later without touching the RouterOS integration.

Architecture: classic MVC, kept boring on purpose

routes/admin.js       → maps HTTP verbs + paths to controller actions
controllers/admin.js  → HTTP concerns: parse req, call API, render a view
controllers/api.js    → the only file that knows about MikroNode/RouterOS
controllers/error.js  → 404 handling
views/admin/*.ejs      → server-rendered Bootstrap views

This is intentionally a server-rendered EJS app, not a SPA. For an internal ops tool used by a handful of admins to manage hotspot users, a client-side framework would have added a build step and a bundler for zero practical benefit — every screen is a table or a form, and EJS with Bootstrap renders them in a request. The views/includes/ partials (head, navigation, end) keep the layout DRY without a templating framework beyond what EJS already provides.

The route table mirrors the operations an ISP admin actually performs against /ip/hotspot/user:

RouteVerbRouterOS command issued
/admin/userlistGET/ip/hotspot/user/print
/admin/userlist/:uIdGET/ip/hotspot/user/print filtered by ?name
/admin/adduserPOST/ip/hotspot/user/add
/admin/edituser/:uIdGET/POST/ip/hotspot/user/print then /ip/hotspot/user/set
/admin/removeuserPOST/ip/hotspot/user/remove
/admin/profilelistGET/ip/hotspot/profile/print

Every one of those RouterOS commands returns MikroTik's flat, semi-typed sentence format rather than JSON, so MikroNode.resultsToObj() does the normalization, and each controller maps the result into exactly the shape its view needs — e.g. getUserList reduces the raw packet down to { user, profile, uptime, id } before it ever reaches userList.ejs, so the template has zero knowledge of the RouterOS wire format.

What we'd harden for production

This was built as a functional MVP against a single hardcoded router IP and credentials, which is honest about its scope but is the first thing our team would change for a multi-tenant or production deployment:

  • Externalize router config. IP, port, username, and password are currently literals in controllers/api.js — trivially moved to environment variables, and necessary before this could manage more than one router.
  • Multi-router support. The connection is opened once at module load and closed over the app's lifetime. Supporting the "Server List" the nav bar already has a placeholder for means turning that into a connection pool keyed by router ID.
  • Auth on the admin panel itself. Nothing in routes/admin.js currently gates access — anyone who can reach the Express port can add or remove hotspot users. A session or JWT layer in front of the router routes is the obvious next step.

Takeaway

HotPot is a small project, but it's a clean example of integrating a browser-facing app with a stateful, non-HTTP protocol without leaking that protocol's shape into the rest of the codebase. The MikroTik API is asynchronous, stream-oriented, and returns data in its own format — none of that is visible past controllers/api.js. Everything above it is a normal Express app: routes, controllers, server-rendered views. That boundary is what makes the difference between a one-off script and something a team could actually extend — add a router, add a route, add a view, and the RouterOS plumbing never has to change.