Replacing the Campus Pi With a MikroTik RB750Gr3

The Pi 4 setup from the previous post worked well enough, but it always felt overweight. A full Ubuntu 24.04 install running hostapd, AdGuard Home, iptables rules, and a systemd timer — all to do what a cheap router should do natively. When my MikroTik RB750Gr3 freed up, I decided to move the campus role over to it and retire the Pi 4.

The MikroTik is physically smaller, draws less power, has five gigabit ports, and RouterOS handles NAT and DHCP natively. The only thing it can't do that the Pi could is run hostapd — but a TP-Link Archer AX50 in AP mode covers that. The main challenge: porting the FortiGate captive portal auto-login from a bash script to RouterOS scripting language.

The New Stack

ether1 ── campus FortiGate (DHCP → 10.5.x.x)
ether2–5 ── bridge → 192.168.88.0/24
             └── TP-Link Archer AX50 (AP mode)
             └── devices
  • RouterOS 6.49.17 on RB750Gr3 (hEX)
  • NAT masquerade on WAN interface list (ether1)
  • portal-watchdog RouterOS script, scheduler every 5 min + DHCP trigger on ether1
  • DNS: 1.1.1.1 / 8.8.8.8 (public fallback, dynamic from campus DHCP)

No AdGuard Home at campus for now — it's on the roadmap, but authenticated internet comes first.

Porting the Portal Watchdog

The bash version on the Pi was around 60 lines. The RouterOS version is conceptually identical but in a completely different language:

:local portalHost "10.5.0.1"
:local portalPort "1000"
:local portalUser "YOUR_USERNAME"
:local portalPass "YOUR_PASSWORD"
:local checkUrl "http://cp.cloudflare.com/"
:global portalKeepaliveUrl

# Ping keepalive if we have a URL stored
:if ([:typeof $portalKeepaliveUrl] != "nothing") do={
    :if ([:len $portalKeepaliveUrl] > 0) do={
        :do { /tool fetch url=$portalKeepaliveUrl output=none } on-error={}
        :log info "portal-watchdog: keepalive pinged"
    }
}

# Check online: Cloudflare returns 204 (empty body) when authenticated
:local body ""
:local done false
:do {
    :local r [/tool fetch url=$checkUrl output=user as-value]
    :set body ($r->"data")
    :if ([:len $body] = 0) do={
        :log info "portal-watchdog: online"
        :set done true
    }
} on-error={
    :log warning "portal-watchdog: check fetch failed"
    :set done true
}

:if (!$done) do={
    :log info "portal-watchdog: offline, searching for token"
    :local needle "fgtauth?"
    :local npos [:find $body $needle]
    :if ([:typeof $npos] = "nothing") do={
        :log warning "portal-watchdog: no fgtauth token in response"
    } else={
        :local tstart ($npos + [:len $needle])
        :local rem [:pick $body $tstart [:len $body]]
        :local qpos [:find $rem "\""]
        :if ([:typeof $qpos] = "nothing") do={ :set qpos [:find $rem "'"] }
        :if ([:typeof $qpos] = "nothing") do={
            :log warning "portal-watchdog: token end not found"
        } else={
            :local magic [:pick $rem 0 $qpos]
            :log info ("portal-watchdog: magic=" . $magic)

            # Visit fgtauth URL to load form state
            :do { /tool fetch url=("http://" . $portalHost . ":" . $portalPort . "/fgtauth?" . $magic) output=none } on-error={}

            # POST credentials
            :local resp ""
            :do {
                :local r [/tool fetch \
                    url=("http://" . $portalHost . ":" . $portalPort . "/?" . $magic) \
                    http-method=post \
                    http-header-field="Content-Type: application/x-www-form-urlencoded" \
                    http-data=("4Tredir=http%3A%2F%2Fcp.cloudflare.com%2F&magic=" . $magic . "&username=" . $portalUser . "&password=" . $portalPass) \
                    output=user as-value]
                :set resp ($r->"data")
            } on-error={ :log warning "portal-watchdog: POST failed" }

            # Extract keepalive URL
            :local kaSearch ("http://" . $portalHost . ":" . $portalPort . "/keepalive?")
            :local kapos [:find $resp $kaSearch]
            :if ([:typeof $kapos] != "nothing") do={
                :local fromka [:pick $resp $kapos [:len $resp]]
                :local kaend [:find $fromka "\""]
                :if ([:typeof $kaend] != "nothing") do={
                    :set portalKeepaliveUrl [:pick $fromka 0 $kaend]
                    :log info ("portal-watchdog: keepalive=" . $portalKeepaliveUrl)
                }
            } else={
                :log warning "portal-watchdog: no keepalive URL in response"
            }

            # Verify login worked
            :do {
                :local vr [/tool fetch url=$checkUrl output=user as-value]
                :if ([:len ($vr->"data")] = 0) do={
                    :log info "portal-watchdog: login OK"
                } else={
                    :log warning "portal-watchdog: post-login verify failed"
                }
            } on-error={ :log warning "portal-watchdog: verify fetch failed" }
        }
    }
}

The keepalive URL is stored in a global variable ($portalKeepaliveUrl) rather than a file. Global variables survive until reboot — which is fine, since on reboot the script re-runs the login flow and sets it again.

The Footguns

Dollar Signs Vanish

The script has to be deployed via SSH since MikroTik's SCP subsystem requires password auth even when SSH uses key auth. The natural approach is:

subprocess.run(['ssh', 'admin@192.168.88.1',
    f'/system script add name="portal-watchdog" source="{escaped}"'])

The problem: RouterOS expands $variables inside quoted strings at the time you run the command, not when the script executes. So $portalHost becomes "" (undefined in the command context) and the stored script source has empty variables everywhere.

Fix: escape every $ as \$ before embedding in the source string. RouterOS then stores the literal $ which becomes a variable reference when the script runs.

escaped = (script
    .replace('\\', '\\\\')   # backslashes first
    .replace('"',  '\\"')    # then double quotes
    .replace('$',  '\\$')   # then dollar signs — prevents expansion during add
    .replace('\n', '\\n'))   # newlines last

Order matters: do backslashes before anything else or you'll double-escape the backslashes you just added.

Space-Separated Paths, Not Slashes

When passing commands to RouterOS via SSH as a single argument, use space-separated paths:

# Works
/system script print

# Fails with "expected command name (line 1 column 8)"
/system/script/print

The slash-separated form works in interactive terminals and import files but not when the command is passed as an SSH argument string.

DHCP Client Trigger

The scheduler handles the 5-minute keepalive, but you want the login to fire immediately when ether1 gets a DHCP lease — otherwise you sit unauthenticated for up to 5 minutes after plugging in. RouterOS DHCP client has a script field for exactly this:

/ip dhcp-client set [find interface=ether1] \
    script="/system script run portal-watchdog"

Scheduler + Boot Trigger

/system scheduler add \
    name="portal-watchdog" \
    interval=5m \
    on-event="/system script run portal-watchdog" \
    start-time=startup

Combined with the DHCP client script trigger, the login fires on IP assignment and then every 5 minutes for keepalive. Sessions last about 3.3 hours, so the 5-minute ping is comfortably within that window.

What Stayed the Same

The FortiGate portal logic is identical to the bash version — same token extraction, same POST, same keepalive ping pattern. RouterOS /tool fetch is less ergonomic than curl but capable enough.

What's Better

  • No Ubuntu to update, no systemd to misconfigure
  • Survives port resets immediately — plug into any of ether2–5, get DHCP, done
  • Five wired ports means I can add the TP-Link AP plus a desktop plus spare ports without a switch