Hardening the box you're logged in through

Assumes you've SSH'd into something you couldn't physically reach.

The last thing I did in that thread was switch off the instrument.

pkill -f 'port-test'

A loop had been polling Transmission's port-test every fifteen seconds — an external server asked to connect back to the public IP and report whether it got through. It had been printing false for minutes. I had just read the router's dst-nat table, found a one-digit typo in it, fixed the typo, and then explained at length that the typo didn't matter, because the ISP was doing carrier-grade NAT and no port forward could ever work. Then I killed the poller, on the reasoning that it couldn't flip true from there.

It would have flipped true on its next tick. That one is its own post.

What follows is the rest of the same night, on the same box. The poller's last line is stamped 03:17:45. The next thing worth writing about is stamped 03:35:20, and in between, something changed in how I was working. Nothing went in without a test attached, and the test was written before the change.

I'd like to claim that's temperament. More likely it's what being wrong out loud, in a log, does to a person for the rest of a night.

Read-only passes came first, and the third had no findings agenda#

Three sweeps ran before anything was written. The first two went wide — firmware, the service list, the firewall filter, users, SSH config, then MAC-server exposure, the bandwidth server, UPnP, RoMON, the NTP client, the L2TP server. Neither wrote a thing. What they turned up is the other half of this session.

The third sweep is the one worth copying, because it had no findings agenda at all. It ran to answer two questions standing in front of two planned deletions: is IPv6 actually in use, and is that L2TP server somebody's VPN? The answers were "only link-locals, nothing exposed" and "there's a vpn PPP secret configured, so yes." IPv6 got left alone instead of cleaned up. The L2TP server stayed, and got its auth list narrowed to mschap2 — hardening a service rather than removing it.

A wrecking ball and an audit produce identical service lists. The difference is entirely whether a pass like that happened first.

Then the batch: the remaining changes as a single command, composed so no line in it could cut the connection carrying it. The one to read carefully is /ip/service/set ssh address=<LAN subnet>;. That restricts SSH to the LAN, and the session issuing it originates on that LAN — it narrows the door without closing the one you're standing in. Had the subnet been mistyped, or had I been coming in over the tunnel instead, that line would have been the last thing the router ever heard from me.

Changing crypto on the only way in — the rollback goes in the || branch first#

strong-crypto=yes earns the paranoia. It changes cipher, MAC and host-key policy on the single service holding the management path open, and when it goes wrong the failure is on the far side of the thing you just broke.

ssh mikrotik '/ip/ssh/set strong-crypto=yes; :put "strong-crypto set"'
echo "=== fresh connection test ==="
ssh -o BatchMode=yes -o ConnectTimeout=8 mikrotik '/system/clock/print' | head -4 || {
  echo "hostkey changed — refreshing known_hosts"
  ssh-keygen -R 192.168.88.1 >/dev/null 2>&1
  ssh -o StrictHostKeyChecking=accept-new mikrotik '/system/clock/print' | head -4
}
strong-crypto set
=== fresh connection test ===
                  time: 03:35:20
                  date: 2026-07-07
  time-zone-autodetect: yes
        time-zone-name: <timezone>

The host key was unchanged, so the fallback had nothing to do. It also could not have fired. Without set -o pipefail, a pipeline's exit status is its last command's, so the || was testing head, and head succeeds on empty input. A failed ssh would have printed nothing and taken the happy path. The version that tests the connection itself captures the output first:

ROUTER=192.168.88.1   # the address the "mikrotik" alias points at
if out=$(ssh -o BatchMode=yes -o ConnectTimeout=8 mikrotik '/system/clock/print'); then
  printf '%s\n' "$out" | head -4
else
  echo "fresh connection failed — refreshing known_hosts"
  ssh-keygen -R "$ROUTER" >/dev/null 2>&1
  ssh -o StrictHostKeyChecking=accept-new mikrotik '/system/clock/print' | head -4
fi

Writing the fallback is still right, bug and all: it costs a few lines at compose time and is unwritable at the moment you'd need it, because at that moment you have no shell on the router. It just has to test the right exit status.

ConnectTimeout=8 does quiet work in the same direction. Without it, a broken crypto negotiation hands you a hang rather than an error, and a hang never reaches the fallback.

That's the whole reason for opening a fresh connection rather than trusting the one already up. The session you're typing in is not evidence that a new one will succeed — long-lived SSH keeps working happily across a config change that has already broken every future login. The fresh connect is the only test that means anything, and you run it while you still hold the old session as a lifeline.

BatchMode=yes, so a pass can't be a password prompt#

Same flag, different job, earlier that night. Getting a key onto the router had gone sideways: the password that worked in WebFig didn't work over SSH, which killed the scp-then-import path outright. The key went in as /file add name=… contents="…" pasted into the router's own terminal, a trick that dissolves the transport problem entirely.

The test afterwards was one line.

ssh -o BatchMode=yes -o ConnectTimeout=8 mikrotik "/system/identity/print; /system/resource/print"

Without BatchMode=yes, that command can pass by falling back to a password prompt. Drive it from a script or a tool wrapper and the prompt is invisible — you have tested nothing, and you believe you tested key auth. BatchMode disables interactive prompting entirely, so the command either authenticates with the key or fails.

It answered with an uptime of 13w5d18h10m6s. Ninety-six days of the router working perfectly, which is exactly how it got into the state that needed auditing.

401 and 409: the pass that looks like a failure#

Different daemon, same discipline. The systemd sandbox around Transmission got checked with one command asserting three separate things — that home is invisible inside the namespace, that unauthenticated RPC is rejected, and that authenticated RPC is accepted.

[…]
=== home hidden inside sandbox? (expect empty/minimal) ===
.
..
.config
=== RPC no-auth rejected? ===
401
=== RPC auth accepted? ===
409

., .., .config, nothing else. Inside the namespace /home/user is a tmpfs carrying only the bind-mounted config directory. SSH keys, browser profiles and git credentials are simply absent from an escaped script-torrent-done command's filesystem view.

401 unauthenticated is the obvious half. 409 with credentials is the half worth knowing: that's Transmission's CSRF handshake refusing a request without X-Transmission-Session-Id, which the server only bothers to demand once authentication has already succeeded. A 409 there means the credentials were accepted.

Scripted as [ "$code" = "200" ], a correctly-built cage reports itself broken, and the natural response to a cage that reports itself broken is to loosen it. You know which of those two codes you'd have written into a health check at three in the morning.

"result":"success" is not a verification#

The one this night had to learn twice. Transmission's RPC will accept a session-set for a field that doesn't exist at session scope, and tell you it worked.

RPC=http://127.0.0.1:9091/transmission/rpc
SID=$(curl -s $RPC | grep -oP 'X-Transmission-Session-Id: \K[^<]+')
curl -s -H "X-Transmission-Session-Id: $SID" \
  -d '{"method":"session-set","arguments":{"rpc-whitelist":"127.0.0.1,::1,192.168.*.*,10.8.0.*"}}' $RPC
curl -s -H "X-Transmission-Session-Id: $SID" \
  -d '{"method":"session-get","arguments":{"fields":["rpc-whitelist"]}}' $RPC
{"arguments":{},"result":"success"}
{"arguments":{},"result":"success"}

The first line answers the session-set, the second the session-get. The second is the interesting one. session-get, asked for the same field back, returns an empty arguments object: the field does not exist at session scope. The write went nowhere and said "result":"success" on the way out.

Third step, against the store the daemon actually loads:

grep -E '"rpc-whitelist"' /home/user/.config/transmission-daemon/settings.json
#     "rpc-whitelist": "127.0.0.1,::1,192.168.*.*",

Unchanged. rpc-* are server config, not session state, and the procedure that actually works has its own trap — stop, edit, start, in that order and no other.

Three reads, three different lies ruled out. The write claimed success, which proves the request parsed and nothing more. The read returned nothing, which means the field was never there to set. The store never moved.

The last command of the night#

The router had to be rebooted — RouterBOARD bootloader firmware is only staged by /system/routerboard/upgrade, and applied by the following boot. It came back, and the verification was two commands settling three claims.

transmission inbound after reboot: port-is-open=true
firmware: 7.22.1  ntp: synchronized

port-is-open=true. That is the same test — the same external server, calling back to the same port — that I had switched off with pkill earlier that night rather than let it finish. It's now what runs after any change that could plausibly touch inbound.

And ntp: synchronized is a different word from enabled. enabled is what I set. synchronized is what the router achieved. Which is the one rule worth taking out of all of this: check the field the daemon earned, not the field you wrote.

Nothing broke after 03:17, and the same hands had been sloppy on the same keyboard under twenty minutes before. What changed is that from 03:17 on, the proof got written before the change, and unlike temperament, that part you can install.

Filed under: mikrotik, routeros, ssh, security, systemd

Sources#

  • systemd.exec(5) — ProtectHome=, BindPaths=, ReadWritePaths=, and why a path hidden by ProtectHome=tmpfs needs a bind rather than a read-write grant.
  • ssh_config(5) — BatchMode, ConnectTimeout, StrictHostKeyChecking. BatchMode=yes disables all interactive prompting; that is what makes a key-auth test conclusive.
  • ssh-keygen(1) — -R removes a host from known_hosts; the pre-written half of the strong-crypto rollback.
  • RouterOS: SSH — /ip/ssh settings including strong-crypto and the yes-if-no-key password default.
  • RouterOS: RouterBOARD — /system/routerboard/upgrade only stages bootloader firmware; the following boot applies it.
  • RouterOS: NTP — client status values, and the gap between enabled and synchronized.
  • RouterOS: Services — the /ip/service list and the per-service address= restriction used in the batch.
  • Transmission RPC specification — session-set/session-get field scope and the X-Transmission-Session-Id handshake behind the 409.