The guard rails that said no

Script Error: failure: mountpoint usb1-part1 is used by log->file:/blockylog.0.txt (/disk/set *0 = *0) (/disk/set; line 7) (:import; line 1)
ABORT: whole SSD still shared (smb/media entries: 2)

That was the deploy script for the MQTT broker on the router, stopping itself at step 0. Nothing after that line ran: no router backup, no password file, no broker. The second line had been added to the script two minutes earlier.

The project behind the power station bridge hit several refusals like this one in a single day. Some came from scripts, one from a permission check in my tooling, one from a review note. Each one made the result better. The only expensive failure of the day came from a tool that didn't refuse at all.

Why the deploy script had an abort line#

The plan was to put the broker's data on the router's USB SSD, next to the blocky ad-blocker that already lived there. Before the first deploy, a look at what was already on that disk showed how it was exposed. RouterOS shares a USB disk automatically, and this one was shared whole: SMB to the guest user with no password, on every interface, plus a DLNA server announcing the full partition on the LAN.

At that point the disk held only blocky's config, logs and container files. The deploy was about to add three things that should never sit on a guest share: a router backup, written unencrypted by the first version of the script; the flash dumps of two ESP32 boards, which can hold Wi-Fi credentials; and the broker's password file.

So the script grew a step 0. It turns off disk-level sharing, shares a media/ folder only, and then checks its own work: if any SMB or DLNA entry still points at the whole disk, it stops before writing anything. Step 1, the backup, now encrypts with a generated password.

The disk was busy#

Step 0 failed on its first run, which is the output at the top. RouterOS won't change a disk's settings while something is writing to it, and blocky's log was being written to a file on that disk. The sharing flags never changed, the check counted two entries still exposing the disk, and the script stopped.

The fix was a maintenance window inside the same script. It turns off the DNS redirect to blocky, so clients fall back to the router's own resolver with no ad-blocking, disables the logging rule that writes blocky's log file, stops the blocky container, changes the disk, then restores all three in reverse order, even if the disk change fails. Ad-blocking goes away for about 75 seconds. The second run went through, and the broker came up a few minutes later.

The first line of that output is RouterOS explaining the failure. The second is the guard doing its job.

The firmware the permission check refused#

My tooling has a permission check that looks at each change before it is made. Late in the afternoon, it refused to write a new version of the bridge firmware. That version could take a firmware update over the network in two ways: an ArduinoOTA push with a password, and an ota <url> command on MQTT that downloads an image over plain HTTP and flashes it, unsigned. The check put that in the remote-code-execution category and stopped the write. Nothing was written or flashed.

The broker access list and an admin-only command topic were supposed to make that safe. The check looked past them at what the firmware could do, and on reflection that was the right call. An unsigned HTTP pull is a way to run arbitrary code on a device on the LAN, whoever is allowed to ask.

The block wasn't worked around. That left three options: everything, password push only, or observe-and-safe-commands only. I picked a split: write the full feature set, but run only the safe variant on the real board. In code, that became compile-time flags. Both update paths sit behind them, and they default to off.

Two builds, then a check that the safe one really lacks the update code. It looks for strings from the update paths, plus two command strings as a control:

C {'ota push ready': False, 'ota pull': False, 'espota': False, 'x-ESP32-sketch-md5': False, 'cmd: %s': True, 'ble-scan': True}
A {'ota push ready': True, 'ota pull': True, 'espota': False, 'x-ESP32-sketch-md5': True, 'cmd: %s': True, 'ble-scan': True}

C is the board's build, and A is the build with everything enabled. Three of the update markers are absent from C and present in A. The fourth, espota, is in neither binary, so it proves nothing and the check doesn't rest on it. The command strings are in both, as they should be. The default build is 93,772 bytes smaller. It also can't be updated over the network at all: changing its firmware takes a USB cable and someone standing next to it.

The fix that made a single copy#

Encrypting the router backup brought a problem of its own, and a review note flagged it. The script had generated the password into router/secrets.env, a file kept out of git on purpose, and nowhere else. One dead laptop disk, and the backup is a file nobody can open. It would be needed exactly when the router has to be rebuilt somewhere else.

The MQTT passwords in that file didn't matter in the same way: both can be regenerated, the bridge's at the cost of a rebuild and a USB reflash. The router export in plain text is no rescue either. It opens without a password because it carries no secrets, so restoring from it means typing every Wi-Fi and VPN key back in.

Encrypting the backup was the right call, and it also left the only key in one place. The fix took one step from me: about forty minutes later, the file was in my password manager.

The tool that said yes#

The day's one real loss happened early in the afternoon, before the first unplug test, and nothing refused anything.

Two research runs had each written their sources to a links file. Merging them was a one-liner: command grep -h '^- ' over each file into LINKS.md, then wc -l, then rm of the two source files. On this machine command grep doesn't reach grep. It reaches a wrapper with its own command line, which printed its usage text instead of matching lines and exited in a way that let the && chain carry on. The line counts came back without an error:

56 LINKS-bluetti.md
38 LINKS-dji.md
45 LINKS.md
Σ 139

45 lines from 56 and 38 should have been a tell. Apart from the section headings the merge echoed itself, every line of LINKS.md was usage text, and the two source files were already gone. Seven seconds after the merge command, a count of URLs in the merged file came back 0. Both lists came back from the research runs' own logs, which still held the writes to those links files:

bluetti 54
dji 35

That was 89 sources back, nine seconds after the zero, and only because those logs happened to exist. The abort line, the permission check and the review note each held the work up, the permission check for about ten minutes. The wrapper held nothing up, and it was the only one that let a deletion through.

Filed under: mikrotik, esp32, security, process

Sources#