Browse Source

feat: graceful MikroTik/RouterOS power-off via ROS API ([mikrotik] config)

Alexander Korolev 1 month ago
parent
commit
e3d6c0401b

+ 24 - 0
AGENTS.md

@@ -626,6 +626,30 @@ commands = ["cmd", { command = "cmd", on_failure = "warn" }]
 - `on`: HA `turn_on` (no SSH, requires HA config)
 - `switch --from <src> --to <tgt>`: disable nodes/extras unique to `from`, enable all in `to`
 
+### RouterOS devices (`[mikrotik]` config)
+
+A cluster entity that names a `[mikrotik]` device is **not** an HA switch —
+it is reached over the RouterOS API instead (`src/mikrotik.rs`, raw TCP
+8728, variable-length word framing):
+
+```toml
+[mikrotik]
+[[mikrotik.devices]]
+name = "switch.dgx1"            # matched against node/entity names exactly
+address = "192.168.88.1"
+username = "admin"
+password = "secret"
+api_port = 8728                  # optional, default 8728
+on_entity = "switch.lab_relay"   # optional: HA relay that powers it back on
+```
+
+- `power off` → `login` + `/system/shutdown` per matched device (after the
+  HA off phase; a failed API call warns, never blocks the rest of the flow).
+- `power on` → `on_entity` relay via HA if set, otherwise a
+  “power on manually” reminder (the API cannot wake an off router).
+- `health` entity polling skips mikrotik names (they are not HA entities).
+- Without a `[mikrotik]` section everything behaves exactly as before.
+
 ## NCCL_IB_GID_INDEX auto-detect
 
 The RoCE-v2 IPv4 GID index re-numbers across reboots on DGX Spark, so a pinned

+ 8 - 1
Cargo.lock

@@ -1701,6 +1701,12 @@ version = "0.8.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
 
+[[package]]
+name = "md5"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
+
 [[package]]
 name = "md5"
 version = "0.8.0"
@@ -2392,7 +2398,7 @@ dependencies = [
  "internal-russh-num-bigint",
  "keccak",
  "log",
- "md5",
+ "md5 0.8.0",
  "ml-kem",
  "module-lattice",
  "num-bigint",
@@ -2854,6 +2860,7 @@ dependencies = [
  "flate2",
  "fluent-i18n",
  "futures",
+ "md5 0.7.0",
  "notify",
  "reqwest",
  "russh",

+ 1 - 0
Cargo.toml

@@ -15,6 +15,7 @@ toml_edit = "0.22"
 tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
 sha2 = "0.11"
+md5 = "0.7"
 base64 = "0.22"
 futures = "0.3"
 walkdir = "2"

+ 6 - 0
locales/de/main.ftl

@@ -53,6 +53,12 @@ exec-section = --- { $name } ---
 exec-failed = ✗ { $name } :: { $cmd } :: { $error }
 exec-completed-errors = exec mit Fehlern abgeschlossen (einige Knoten meldeten Fehler)
 power-off-dryrun = dry-run: tatsächliches Herunterfahren + HA-Aufruf übersprungen
+power-off-mikrotik-skip = { $entity } ist ein RouterOS-API-Gerät ({ $address }) — HA wird übersprungen
+power-off-mikrotik-would = würde: RouterOS /system/shutdown { $name } ({ $address })
+power-off-mikrotik-failed = { $name }: RouterOS-Shutdown fehlgeschlagen ({ $error })
+power-on-mikrotik-manual = { $name }: RouterOS-Gerät — kein Remote-Einschalten; manuell einschalten
+power-on-mikrotik-manual-would = würde: { $name} manuell einschalten (RouterOS hat kein Remote-On)
+power-on-mikrotik-relay-would = würde: Relais { $relay} einschalten (versorgt { $name} mit Strom)
 power-off-would = würde: { $action } { $target }
 power-off-shutdown-failed = ✗ { $name } :: { $cmd } :: { $error }
 power-off-waiting = ⏳ warte { $secs}s bis Knoten sich beruhigen

+ 6 - 0
locales/en/main.ftl

@@ -53,6 +53,12 @@ exec-section = --- { $name } ---
 exec-failed = ✗ { $name } :: { $cmd } :: { $error }
 exec-completed-errors = exec completed with errors (some nodes reported failures)
 power-off-dryrun = dry-run: skipping actual shutdown + HA call
+power-off-mikrotik-skip = { $entity } is a RouterOS API device ({ $address }) — HA skipped
+power-off-mikrotik-would = would: RouterOS /system/shutdown { $name } ({ $address })
+power-off-mikrotik-failed = { $name }: RouterOS shutdown failed ({ $error })
+power-on-mikrotik-manual = { $name }: RouterOS device — no remote power-on; power it on manually
+power-on-mikrotik-manual-would = would: power on { $name } manually (RouterOS has no remote on)
+power-on-mikrotik-relay-would = would: turn on relay { $relay} (powers on { $name})
 power-off-would = would: { $action } { $target }
 power-off-shutdown-failed = ✗ { $name } :: { $cmd } :: { $error }
 power-off-waiting = ⏳ waiting { $secs }s for nodes to settle

+ 6 - 0
locales/es/main.ftl

@@ -53,6 +53,12 @@ exec-section = --- { $name } ---
 exec-failed = ✗ { $name } :: { $cmd } :: { $error }
 exec-completed-errors = exec completó con errores (algunos nodos reportaron fallos)
 power-off-dryrun = dry-run: omitiendo apagado real + llamada HA
+power-off-mikrotik-skip = { $entity } es un dispositivo de la API de RouterOS ({ $address }) — HA se omite
+power-off-mikrotik-would = haría: RouterOS /system/shutdown { $name } ({ $address })
+power-off-mikrotik-failed = { $name }: fallo el apagado de RouterOS ({ $error })
+power-on-mikrotik-manual = { $name }: dispositivo RouterOS — sin encendido remoto; enciéndalo manualmente
+power-on-mikrotik-manual-would = haría: encender { $name} manualmente (RouterOS no tiene on remoto)
+power-on-mikrotik-relay-would = haría: activar el relé { $relay} (enciende { $name})
 power-off-would = sería: { $action } { $target }
 power-off-shutdown-failed = ✗ { $name } :: { $cmd } :: { $error }
 power-off-waiting = ⏳ esperando { $secs}s para que los nodos se asienten

+ 6 - 0
locales/fr/main.ftl

@@ -53,6 +53,12 @@ exec-section = --- { $name } ---
 exec-failed = ✗ { $name } :: { $cmd } :: { $error }
 exec-completed-errors = exec terminé avec des erreurs (certains nœuds ont signalé des échecs)
 power-off-dryrun = dry-run : extinction réelle + appel HA ignorés
+power-off-mikrotik-skip = { $entity } est un appareil API RouterOS ({ $address }) — HA ignoré
+power-off-mikrotik-would = ferait : RouterOS /system/shutdown { $name } ({ $address })
+power-off-mikrotik-failed = { $name } : arrêt RouterOS en échec ({ $error })
+power-on-mikrotik-manual = { $name } : appareil RouterOS — pas d'allumage distant ; allumer manuellement
+power-on-mikrotik-manual-would = ferait : allumer { $name} manuellement (RouterOS n'a pas d'on distant)
+power-on-mikrotik-relay-would = ferait : activer le relais { $relay} (alimente { $name})
 power-off-would = serait : { $action } { $target }
 power-off-shutdown-failed = ✗ { $name } :: { $cmd } :: { $error }
 power-off-waiting = ⏳ attente { $secs}s pour stabilisation des nœuds

+ 6 - 0
locales/it/main.ftl

@@ -53,6 +53,12 @@ exec-section = --- { $name } ---
 exec-failed = ✗ { $name } :: { $cmd } :: { $error }
 exec-completed-errors = exec completato con errori (alcuni nodi hanno riportato fallimenti)
 power-off-dryrun = dry-run: saltando spegnimento reale + chiamata HA
+power-off-mikrotik-skip = { $entity } è un dispositivo API RouterOS ({ $address }) — HA saltato
+power-off-mikrotik-would = eseguirebbe: RouterOS /system/shutdown { $name } ({ $address })
+power-off-mikrotik-failed = { $name }: arresto RouterOS fallito ({ $error })
+power-on-mikrotik-manual = { $name }: dispositivo RouterOS — nessun accensione remota; accendere manualmente
+power-on-mikrotik-manual-would = eseguirebbe: accendere { $name} manualmente (RouterOS non ha on remoto)
+power-on-mikrotik-relay-would = eseguirebbe: attivare il relé { $relay} (alimenta { $name})
 power-off-would = sarebbe: { $action } { $target }
 power-off-shutdown-failed = ✗ { $name } :: { $cmd } :: { $error }
 power-off-waiting = ⏳ attesa { $secs}s per assestamento dei nodi

+ 6 - 0
locales/ru/main.ftl

@@ -56,6 +56,12 @@ exec-section = --- { $name } ---
 exec-failed = ✗ { $name } :: { $cmd } :: { $error }
 exec-completed-errors = exec завершен с ошибками (некоторые узлы не ответили)
 power-off-dryrun = dry-run: пропускаем реальное выключение + HA вызов
+power-off-mikrotik-skip = { $entity } — устройство RouterOS API ({ $address }), HA пропускается
+power-off-mikrotik-would = бы: RouterOS /system/shutdown { $name } ({ $address })
+power-off-mikrotik-failed = { $name }: сбой RouterOS shutdown ({ $error })
+power-on-mikrotik-manual = { $name }: устройство RouterOS — remote power-on невозможен; включите вручную
+power-on-mikrotik-manual-would = бы: включить { $name} вручную (у RouterOS нет remote on)
+power-on-mikrotik-relay-would = бы: включить реле { $relay} (запитывает { $name})
 power-off-would = будет: { $action } { $target }
 power-off-shutdown-failed = ✗ { $name } :: { $cmd } :: { $error }
 power-off-waiting = ⏳ ждем { $secs }с для завершения остановки

+ 11 - 0
recipes/glm53-full-mxfp4-8x/reports/2026-09-18_201444_full8x-512k.json

@@ -0,0 +1,11 @@
+{
+  "schema_version": "1",
+  "tool_eval_bench_version": "2.6.1.dev65+g6be685f0e",
+  "final_score": null,
+  "rating": null,
+  "safety_warnings": [],
+  "deployability": null,
+  "responsiveness": null,
+  "total_scenarios": null,
+  "error": "[Errno 5] Input/output error"
+}

+ 1 - 0
src/after_stop.rs

@@ -175,6 +175,7 @@ mod tests {
             before_serve: std::collections::HashMap::new(),
             exec_aliases: std::collections::HashMap::new(),
             home_assistant: None,
+            mikrotik: None,
             drop_caches: true,
             mount_aliases: std::collections::HashMap::new(),
             recipe_sources: Vec::new(),

+ 1 - 0
src/before_serve.rs

@@ -225,6 +225,7 @@ mod tests {
             before_serve,
             exec_aliases: HashMap::new(),
             home_assistant: None,
+            mikrotik: None,
             global_mods_dir: None,
             drop_caches: true,
             mount_aliases: HashMap::new(),

+ 5 - 0
src/cluster_state/tests.rs

@@ -207,6 +207,7 @@ fn select_smallest_with_enough_free() {
         before_serve: HashMap::new(),
         exec_aliases: HashMap::new(),
         home_assistant: None,
+        mikrotik: None,
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
@@ -237,6 +238,7 @@ fn select_cluster_not_found_errors() {
         before_serve: HashMap::new(),
         exec_aliases: HashMap::new(),
         home_assistant: None,
+        mikrotik: None,
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
@@ -271,6 +273,7 @@ fn no_cluster_has_enough_free_errors() {
         before_serve: HashMap::new(),
         exec_aliases: HashMap::new(),
         home_assistant: None,
+        mikrotik: None,
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
@@ -301,6 +304,7 @@ fn single_node_allows_cluster_without_nccl() {
         before_serve: HashMap::new(),
         exec_aliases: HashMap::new(),
         home_assistant: None,
+        mikrotik: None,
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
@@ -341,6 +345,7 @@ fn multi_node_requires_nccl() {
         before_serve: HashMap::new(),
         exec_aliases: HashMap::new(),
         home_assistant: None,
+        mikrotik: None,
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),

+ 3 - 0
src/config.rs

@@ -362,3 +362,6 @@ pub fn default_config_dir() -> Option<std::path::PathBuf> {
 
 #[cfg(test)]
 mod tests;
+
+#[cfg(test)]
+mod mikrotik_tests;

+ 42 - 0
src/config/mikrotik_tests.rs

@@ -0,0 +1,42 @@
+//! Parse tests for the `[mikrotik]` config section.
+
+use super::Config;
+
+#[test]
+fn test_config_deserialize_mikrotik() {
+    let c: Config = toml::from_str(
+        r#"
+[mikrotik]
+[[mikrotik.devices]]
+name = "router.lab"
+address = "192.168.88.1"
+username = "admin"
+password = "secret"
+
+[[mikrotik.devices]]
+name = "router.edge"
+address = "10.0.0.1"
+username = "u"
+password = "p"
+api_port = 8729
+on_entity = "switch.edge_power"
+"#,
+    )
+    .unwrap();
+    let mk = c.mikrotik.as_ref().expect("[mikrotik] parsed");
+    assert_eq!(mk.devices.len(), 2);
+    assert_eq!(mk.devices[0].name, "router.lab");
+    assert_eq!(mk.devices[0].api_port, 8728, "default port");
+    assert_eq!(mk.devices[0].on_entity, None);
+    assert_eq!(mk.devices[1].api_port, 8729);
+    assert_eq!(
+        mk.devices[1].on_entity.as_deref(),
+        Some("switch.edge_power")
+    );
+}
+
+#[test]
+fn test_config_mikrotik_absent() {
+    let c: Config = toml::from_str("ssh_user = \"a\"\n").unwrap();
+    assert!(c.mikrotik.is_none());
+}

+ 1 - 0
src/config/tests.rs

@@ -39,6 +39,7 @@ fn tcfg() -> Config {
         before_serve: HashMap::new(),
         exec_aliases: HashMap::new(),
         home_assistant: None,
+        mikrotik: None,
         global_mods_dir: None,
         drop_caches: true,
         mount_aliases: HashMap::new(),

+ 32 - 0
src/config/types.rs

@@ -70,6 +70,9 @@ pub struct Config {
     pub exec_aliases: HashMap<String, String>,
     #[serde(default)]
     pub home_assistant: Option<HomeAssistantConfig>,
+    /// RouterOS devices for graceful `power off` (see [`MikrotikConfig`]).
+    #[serde(default)]
+    pub mikrotik: Option<MikrotikConfig>,
     #[serde(default)]
     pub global_mods_dir: Option<std::path::PathBuf>,
     /// Drop page cache before starting containers.
@@ -99,6 +102,35 @@ pub struct HomeAssistantConfig {
     pub shutdown_delay_seconds: u64,
 }
 
+/// `[mikrotik]` — RouterOS devices shut down gracefully over the RouterOS
+/// API (default port 8728) during `power off`. A device's `name` is
+/// referenced verbatim from a cluster's `extra_entities` (or a node's
+/// `entity`); names not present here keep going through Home Assistant.
+#[derive(Clone, Deserialize)]
+pub struct MikrotikConfig {
+    pub devices: Vec<MikrotikDevice>,
+}
+
+#[derive(Clone, Deserialize)]
+pub struct MikrotikDevice {
+    /// Entity-style name used in cluster `extra_entities`.
+    pub name: String,
+    pub address: String,
+    pub username: String,
+    pub password: String,
+    #[serde(default = "default_mikrotik_port")]
+    pub api_port: u16,
+    /// Optional HA relay entity that powers the router on (the API cannot
+    /// wake an off device); without it, `power on` tells the operator to
+    /// plug it in manually.
+    #[serde(default)]
+    pub on_entity: Option<String>,
+}
+
+fn default_mikrotik_port() -> u16 {
+    8728
+}
+
 fn default_shutdown_delay() -> u64 {
     30
 }

+ 1 - 0
src/detect.rs

@@ -152,6 +152,7 @@ mod tests {
             before_serve: Default::default(),
             exec_aliases: Default::default(),
             home_assistant: None,
+            mikrotik: None,
             global_mods_dir: None,
             drop_caches: false,
             mount_aliases: Default::default(),

+ 1 - 0
src/docker/command_tests.rs

@@ -46,6 +46,7 @@ fn minimal_config() -> Config {
         before_serve: HashMap::new(),
         exec_aliases: HashMap::new(),
         home_assistant: None,
+        mikrotik: None,
         global_mods_dir: None,
         drop_caches: true,
         mount_aliases: HashMap::from([("models".to_string(), "/mnt/nas/models".to_string())]),

+ 1 - 0
src/docker/mirror.rs

@@ -159,6 +159,7 @@ mod tests {
             before_serve: HashMap::new(),
             exec_aliases: HashMap::new(),
             home_assistant: None,
+            mikrotik: None,
             global_mods_dir: None,
             drop_caches: true,
             mount_aliases: HashMap::new(),

+ 1 - 0
src/exec.rs

@@ -168,6 +168,7 @@ mod tests {
             before_serve: HashMap::new(),
             exec_aliases,
             home_assistant: None,
+            mikrotik: None,
             drop_caches: true,
             mount_aliases: HashMap::new(),
             recipe_sources: Vec::new(),

+ 10 - 0
src/home_assistant.rs

@@ -157,6 +157,16 @@ pub async fn extra_entities_states(config: &Config, cluster: &Cluster) -> Entity
     };
     let mut handles = Vec::new();
     for e in &cluster.extra_entities {
+        // Mikrotik devices (referenced by exact name from [mikrotik]) are
+        // not HA entities — probing them would report "off" and take the
+        // cluster down for nothing.
+        let is_mikrotik = config
+            .mikrotik
+            .as_ref()
+            .is_some_and(|m| m.devices.iter().any(|d| d.name == e.entity()));
+        if is_mikrotik {
+            continue;
+        }
         let client = client.clone();
         let id = e.entity().to_string();
         let blocks = e.blocks();

+ 1 - 0
src/main.rs

@@ -25,6 +25,7 @@ mod health;
 mod home_assistant;
 mod host_keys;
 mod locale;
+mod mikrotik;
 mod orchestrate;
 mod power;
 mod pre_serve;

+ 391 - 0
src/mikrotik.rs

@@ -0,0 +1,391 @@
+//! Minimal RouterOS API client for graceful MikroTik power-off.
+//!
+//! Protocol per the official MikroTik "Python3 Example" template
+//! (help.mikrotik.com, updated 2026-02): plain TCP (default port 8728),
+//! variable-length word framing (no flags, no CRC encoding), sentences
+//! terminated by an empty word. Login sends `/login =name= =password=`;
+//! modern RouterOS answers `!done` outright, pre-6.43 answers
+//! `!done =ret=<hex>` and expects an MD5 challenge response.
+//!
+//! We only need one command: `/system/shutdown`.
+
+use anyhow::{Context, Result};
+use std::time::Duration;
+use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
+use tokio::net::TcpStream;
+use tracing::info;
+
+use crate::config::MikrotikDevice;
+
+/// Connect + login, then shut the router down via `/system/shutdown`.
+/// One connection per call — the router is about to die anyway.
+pub async fn shutdown_device(dev: &MikrotikDevice) -> Result<()> {
+    let target = format!("{}:{}", dev.address, dev.api_port);
+    let mut s = tokio::time::timeout(Duration::from_secs(10), TcpStream::connect(&target))
+        .await
+        .with_context(|| format!("connecting to RouterOS at {}", target))?
+        .with_context(|| format!("connect to RouterOS at {}", target))?;
+    s.set_nodelay(true)?;
+    login_stream(&mut s, dev).await?;
+    shutdown_stream(&mut s, dev).await
+}
+
+/// The login exchange on an authenticated-or-not stream: `/login` with
+/// username + password, then `!done` (modern) or the legacy MD5
+/// challenge round-trip (pre-6.43).
+async fn login_stream<S>(s: &mut S, dev: &MikrotikDevice) -> Result<()>
+where
+    S: AsyncRead + AsyncWrite + Unpin,
+{
+    send_sentence(
+        s,
+        &[
+            "/login",
+            &format!("=name={}", dev.username),
+            &format!("=password={}", dev.password),
+        ],
+    )
+    .await
+    .context("sending /login")?;
+
+    loop {
+        let mut words = read_sentence(s).await.context("reading /login reply")?;
+        if words.first().is_some_and(|w| w == "#routeros") {
+            words.remove(0); // tolerate the legacy banner word
+        }
+        let rest: Vec<&str> = words.iter().skip(1).map(|w| w.as_str()).collect();
+        match words.first().map(|s| s.as_str()) {
+            Some("!trap") => {
+                anyhow::bail!("RouterOS login rejected ({}): {}", dev.name, rest.join(" "))
+            }
+            Some("!done") => {
+                let Some(hex) = rest.iter().find_map(|w| w.strip_prefix("=ret=")) else {
+                    return Ok(()); // no challenge — logged in
+                };
+                let challenge = hex_decode(hex)
+                    .map_err(|e| anyhow::anyhow!("login challenge is not hex: {hex} ({e})"))?;
+                let mut pre = vec![0u8];
+                pre.extend_from_slice(dev.password.as_bytes());
+                pre.extend_from_slice(&challenge);
+                let md = md5::compute(&pre);
+                send_sentence(
+                    s,
+                    &[
+                        "/login",
+                        &format!("=name={}", dev.username),
+                        &format!("=response=00{}", hex_encode(md.as_slice())),
+                    ],
+                )
+                .await
+                .context("sending login challenge response")?;
+            }
+            _ => anyhow::bail!(
+                "unexpected RouterOS reply during login ({}): {}",
+                dev.name,
+                words.join(" ")
+            ),
+        }
+    }
+}
+
+/// `/system/shutdown` on a logged-in stream; read replies until
+/// `!done` (`!trap` = the router refused, e.g. policy).
+async fn shutdown_stream<S>(s: &mut S, dev: &MikrotikDevice) -> Result<()>
+where
+    S: AsyncRead + AsyncWrite + Unpin,
+{
+    info!("RouterOS graceful shutdown: {}", dev.name);
+    let r = tokio::time::timeout(Duration::from_secs(10), async {
+        send_sentence(s, &["/system/shutdown"])
+            .await
+            .context("sending /system/shutdown")?;
+        loop {
+            let mut words = read_sentence(s).await.context("reading shutdown reply")?;
+            if words.first().is_some_and(|w| w == "#routeros") {
+                words.remove(0);
+            }
+            match words.first().map(|s| s.as_str()) {
+                Some("!trap") => {
+                    anyhow::bail!(
+                        "RouterOS {} refused /system/shutdown: {}",
+                        dev.name,
+                        words
+                            .iter()
+                            .skip(1)
+                            .map(|w| w.as_str())
+                            .collect::<Vec<_>>()
+                            .join(" ")
+                    );
+                }
+                Some("!done") => break,
+                _ => {} // interim `.*` progress lines
+            }
+        }
+        Ok::<(), anyhow::Error>(())
+    })
+    .await;
+    r.context("RouterOS shutdown timed out (10 s)")?
+}
+
+// --- Framing -------------------------------------------------------------
+//
+// Word = variable-length length prefix + raw UTF-8 bytes (no flags).
+//   <0x80                1-byte length
+//   0x80..0xC0           (len | 0x8000),      2 bytes total
+//   0xC0..0xE0           (len | 0xC00000),    3 bytes total
+//   0xE0..0xF0           (len | 0xE0000000),  4 bytes total
+//   0xF0..               5 bytes total, first is a flag only
+
+async fn write_word<W: AsyncWrite + Unpin>(w: &mut W, s: &str) -> std::io::Result<()> {
+    let data = s.as_bytes();
+    let len = data.len();
+    let mut hdr = [0u8; 5];
+    let n = if len < 0x80 {
+        hdr[0] = len as u8;
+        1
+    } else if len < 0x4000 {
+        let v = (len | 0x8000) as u16;
+        hdr[0..2].copy_from_slice(&v.to_be_bytes());
+        2
+    } else if len < 0x200000 {
+        let v = (len | 0xC00000) as u32;
+        hdr[0..3].copy_from_slice(&v.to_be_bytes()[1..]);
+        3
+    } else if len < 0x10000000 {
+        let v = (len | 0xE0000000) as u32;
+        hdr[0..4].copy_from_slice(&v.to_be_bytes());
+        4
+    } else {
+        hdr[0] = 0xF0;
+        hdr[1..5].copy_from_slice(&len.to_be_bytes());
+        5
+    };
+    w.write_all(&hdr[..n]).await?;
+    w.write_all(data).await?;
+    Ok(())
+}
+
+async fn read_u8<R: AsyncRead + Unpin>(r: &mut R) -> std::io::Result<u32> {
+    let mut x = [0u8; 1];
+    r.read_exact(&mut x).await?;
+    Ok(u32::from(x[0]))
+}
+
+async fn read_word_len<R: AsyncRead + Unpin>(r: &mut R) -> std::io::Result<u32> {
+    let mut c = [0u8; 1];
+    r.read_exact(&mut c).await?;
+    Ok(if c[0] < 0x80 {
+        u32::from(c[0])
+    } else if c[0] < 0xC0 {
+        ((c[0] & 0x3F) as u32) << 8 | read_u8(r).await?
+    } else if c[0] < 0xE0 {
+        ((c[0] & 0x1F) as u32) << 16 | read_u8(r).await? << 8 | read_u8(r).await?
+    } else if c[0] < 0xF0 {
+        ((c[0] & 0x0F) as u32) << 24
+            | read_u8(r).await? << 16
+            | read_u8(r).await? << 8
+            | read_u8(r).await?
+    } else {
+        read_u8(r).await? << 24
+            | read_u8(r).await? << 16
+            | read_u8(r).await? << 8
+            | read_u8(r).await?
+    })
+}
+
+async fn read_word<R: AsyncRead + Unpin>(r: &mut R) -> std::io::Result<Vec<u8>> {
+    let len = read_word_len(r).await?;
+    let mut buf = vec![0u8; len as usize];
+    r.read_exact(&mut buf).await?;
+    Ok(buf)
+}
+
+/// One sentence = words + a terminating empty word.
+async fn send_sentence<W: AsyncWrite + Unpin>(w: &mut W, words: &[&str]) -> std::io::Result<()> {
+    for word in words {
+        write_word(w, word).await?;
+    }
+    write_word(w, "").await
+}
+
+async fn read_sentence<R: AsyncRead + Unpin>(r: &mut R) -> std::io::Result<Vec<String>> {
+    let mut words = Vec::new();
+    loop {
+        let raw = read_word(r).await?;
+        if raw.is_empty() {
+            return Ok(words);
+        }
+        words.push(String::from_utf8_lossy(&raw).into_owned());
+    }
+}
+
+fn hex_decode(s: &str) -> std::result::Result<Vec<u8>, String> {
+    let bytes = s.as_bytes();
+    if !bytes.len().is_multiple_of(2) {
+        return Err("odd number of hex digits".to_string());
+    }
+    let val = |c: u8| -> std::result::Result<u8, String> {
+        match c {
+            b'0'..=b'9' => Ok(c - b'0'),
+            b'a'..=b'f' => Ok(c - b'a' + 10),
+            b'A'..=b'F' => Ok(c - b'A' + 10),
+            _ => Err("not a hex digit".to_string()),
+        }
+    };
+    (0..bytes.len())
+        .step_by(2)
+        .map(|i| Ok(val(bytes[i])? * 16 + val(bytes[i + 1])?))
+        .collect()
+}
+
+fn hex_encode(bytes: &[u8]) -> String {
+    let mut s = String::with_capacity(bytes.len() * 2);
+    for b in bytes {
+        s.push_str(&format!("{:02x}", b));
+    }
+    s
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use tokio::io::duplex;
+
+    fn device() -> MikrotikDevice {
+        MikrotikDevice {
+            name: "t".into(),
+            address: "127.0.0.1".into(),
+            username: "admin".into(),
+            password: "secret".into(),
+            api_port: 8728,
+            on_entity: None,
+        }
+    }
+
+    #[tokio::test]
+    async fn framing_roundtrip_all_bands() {
+        for n in [0usize, 1, 127, 128, 16383, 16384, 2_097_151] {
+            // duplex is a bounded pipe — write and read must run concurrently
+            let (mut a, mut b) = duplex(64 * 1024);
+            let s = "x".repeat(n);
+            let s2 = s.clone();
+            let writer = tokio::spawn(async move { write_word(&mut a, &s2).await });
+            let reader = tokio::spawn(async move { read_word(&mut b).await });
+            let got = reader.await.unwrap().unwrap();
+            writer.await.unwrap().unwrap();
+            assert_eq!(got.len(), n, "size {n}");
+            assert_eq!(String::from_utf8(got).unwrap(), s);
+        }
+    }
+
+    #[tokio::test]
+    async fn framing_band5_roundtrip() {
+        // 2 MiB+ payload exercises the 5-byte header band.
+        let (mut a, mut b) = duplex(64 * 1024);
+        let s = "y".repeat(2_100_000);
+        let s2 = s.clone();
+        let writer = tokio::spawn(async move { write_word(&mut a, &s2).await });
+        let reader = tokio::spawn(async move { read_word(&mut b).await });
+        let got = reader.await.unwrap().unwrap();
+        writer.await.unwrap().unwrap();
+        assert_eq!(got.len(), 2_100_000);
+    }
+
+    #[tokio::test]
+    async fn sentence_terminates_on_empty_word() {
+        let (mut a, mut b) = duplex(64 * 1024);
+        tokio::spawn(async move {
+            send_sentence(&mut a, &["/login", "=name=admin"])
+                .await
+                .unwrap();
+        });
+        let words = read_sentence(&mut b).await.unwrap();
+        assert_eq!(words, vec!["/login", "=name=admin"]);
+    }
+
+    /// Client login exchange against a mock RouterOS that answers modern
+    /// style: `!done` with no challenge.
+    #[tokio::test]
+    async fn login_modern_ok() {
+        let (mut c, mut s) = duplex(64 * 1024);
+        tokio::spawn(async move {
+            let sent = read_sentence(&mut s).await.unwrap();
+            assert_eq!(sent, vec!["/login", "=name=admin", "=password=secret"]);
+            send_sentence(&mut s, &["!done"]).await.unwrap();
+        });
+        login_stream(&mut c, &device()).await.unwrap();
+    }
+
+    /// Legacy challenge: `!done =ret=<hex>` → client must answer with the
+    /// MD5 response, then `!done`.
+    #[tokio::test]
+    async fn login_legacy_challenge() {
+        let (mut c, mut s) = duplex(64 * 1024);
+        tokio::spawn(async move {
+            let sent = read_sentence(&mut s).await.unwrap();
+            assert_eq!(sent[0], "/login");
+            send_sentence(&mut s, &["!done", "=ret=deadbeef"])
+                .await
+                .unwrap();
+            let resp = read_sentence(&mut s).await.unwrap();
+            // expected: md5(0x00 || "secret" || 0xde 0xad 0xbe 0xef)
+            let mut pre = vec![0u8];
+            pre.extend_from_slice(b"secret");
+            pre.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
+            let md = md5::compute(&pre);
+            let want = format!("=response=00{}", hex_encode(md.as_slice()));
+            assert_eq!(
+                resp,
+                vec!["/login".to_string(), "=name=admin".to_string(), want],
+                "challenge response"
+            );
+            send_sentence(&mut s, &["!done"]).await.unwrap();
+        });
+        login_stream(&mut c, &device()).await.unwrap();
+    }
+
+    #[tokio::test]
+    async fn login_trap_fails() {
+        let (mut c, mut s) = duplex(64 * 1024);
+        tokio::spawn(async move {
+            let _ = read_sentence(&mut s).await;
+            send_sentence(&mut s, &["!trap", "=message=wrong pass"])
+                .await
+                .unwrap();
+        });
+        let e = login_stream(&mut c, &device()).await.unwrap_err();
+        assert!(e.to_string().contains("wrong pass"), "{}", e);
+    }
+
+    #[tokio::test]
+    async fn shutdown_done() {
+        let (mut c, mut s) = duplex(64 * 1024);
+        tokio::spawn(async move {
+            let sent = read_sentence(&mut s).await.unwrap();
+            assert_eq!(sent, vec!["/system/shutdown"]);
+            send_sentence(&mut s, &["!done"]).await.unwrap();
+        });
+        shutdown_stream(&mut c, &device()).await.unwrap();
+    }
+
+    #[tokio::test]
+    async fn shutdown_trap_fails() {
+        let (mut c, mut s) = duplex(64 * 1024);
+        tokio::spawn(async move {
+            let _ = read_sentence(&mut s).await;
+            send_sentence(&mut s, &["!trap", "=message=denied"])
+                .await
+                .unwrap();
+        });
+        let e = shutdown_stream(&mut c, &device()).await.unwrap_err();
+        assert!(e.to_string().contains("denied"), "{}", e);
+    }
+
+    #[test]
+    fn hex_roundtrip() {
+        let bytes: Vec<u8> = vec![0xde, 0xad, 0x00, 0xff, 0x01];
+        assert_eq!(hex_decode(&hex_encode(&bytes)).unwrap(), bytes);
+        assert!(hex_decode("abc").is_err());
+        assert!(hex_decode("zz").is_err());
+    }
+}

+ 1 - 0
src/orchestrate/tests.rs

@@ -87,6 +87,7 @@ fn test_config(clusters: Vec<Cluster>) -> Config {
         before_serve: HashMap::new(),
         exec_aliases: HashMap::new(),
         home_assistant: None,
+        mikrotik: None,
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),

+ 135 - 107
src/power.rs

@@ -15,9 +15,11 @@ use tracing::{info, warn};
 
 use crate::home_assistant::split_entity;
 
-use crate::config::{Cluster, Config, Node};
+use crate::config::{Cluster, Config, MikrotikConfig, Node};
 use crate::home_assistant::Client as HaClient;
 
+mod mikro;
+
 /// Flow for `power off`: SSH shutdown + wait + HA `turn_off`.
 pub async fn power_off(
     config: &Config,
@@ -29,25 +31,7 @@ pub async fn power_off(
 
     if dry_run {
         warn!("{}", fluent_i18n::t!("power-off-dryrun"));
-        for node in &targets {
-            println!(
-                "{}",
-                fluent_i18n::t!("power-off-would", { "action" => format!("ssh {}@{} sudo shutdown now", config.ssh_user, node.ssh_ip), "target" => String::new() })
-            );
-            if let Some(entity) = &node.entity {
-                println!(
-                    "{}",
-                    fluent_i18n::t!("power-off-entity-would", { "entity" => entity })
-                );
-            }
-        }
-        for extra in &cluster.extra_entities {
-            let id = extra.entity().to_string();
-            println!(
-                "{}",
-                fluent_i18n::t!("power-switch-would-off-extra", { "extra" => &id })
-            );
-        }
+        mikro::dry_off(config, &targets, &extra_ids(cluster));
         return Ok(());
     }
 
@@ -96,13 +80,28 @@ pub async fn power_off(
     tokio::time::sleep(Duration::from_secs(delay)).await;
 
     // 3) HA turn_off: per-node entities, then cluster-level extras.
-    if let Some(ha_cfg) = &config.home_assistant {
+    //    Mikrotik names are skipped here — they get a graceful RouterOS
+    //    API shutdown in step 4 (after HA, so the router dies last).
+    let mikrotik_off: Vec<String> = if let Some(ha_cfg) = &config.home_assistant {
         let ha = HaClient::new(ha_cfg)?;
         crate::home_assistant::warn_if_token_looks_short(&ha_cfg.token);
-        toggle_entities(&ha, "turn_off", &targets, &extra_ids(cluster), "cluster").await;
+        toggle_entities(
+            &ha,
+            "turn_off",
+            config.mikrotik.as_ref(),
+            &targets,
+            &extra_ids(cluster),
+            "cluster",
+        )
+        .await
     } else {
         info!("{}", fluent_i18n::t!("power-off-no-ha"));
-    }
+        // No HA at all — still shut down any mikrotik device referenced.
+        mikro::collect_off(config, &targets, &extra_ids(cluster))
+    };
+
+    // 4) RouterOS graceful shutdown for each referenced device.
+    mikro::shutdown_named(config, &mikrotik_off).await;
 
     if any_ssh_failed {
         warn!("{}", fluent_i18n::t!("power-off-ssh-failures"));
@@ -132,26 +131,7 @@ pub async fn power_on(
 
     if dry_run {
         warn!("{}", fluent_i18n::t!("power-on-dryrun"));
-        for node in &targets {
-            if let Some(entity) = &node.entity {
-                println!(
-                    "{}",
-                    fluent_i18n::t!("power-on-would-on", { "entity" => entity })
-                );
-            } else {
-                println!(
-                    "{}",
-                    fluent_i18n::t!("power-on-would-skip", { "name" => &node.name })
-                );
-            }
-        }
-        for extra in &cluster.extra_entities {
-            let id = extra.entity().to_string();
-            println!(
-                "{}",
-                fluent_i18n::t!("power-on-would-on", { "entity" => &id })
-            );
-        }
+        mikro::dry_on(config, &targets, &extra_ids(cluster));
         return Ok(());
     }
 
@@ -159,7 +139,15 @@ pub async fn power_on(
         "{}",
         fluent_i18n::t!("power-on-head", { "nodes" => targets.len() })
     );
-    toggle_entities(&ha, "turn_on", &targets, &extra_ids(cluster), "cluster").await;
+    let _ = toggle_entities(
+        &ha,
+        "turn_on",
+        config.mikrotik.as_ref(),
+        &targets,
+        &extra_ids(cluster),
+        "cluster",
+    )
+    .await;
     Ok(())
 }
 
@@ -172,53 +160,98 @@ fn extra_ids(cluster: &Cluster) -> Vec<String> {
         .collect()
 }
 
-/// Call HA `service` for each per-node entity, then each cluster-level
-/// `extra_entities`.  Validation is the same in both cases (entity IDs
-/// must be `domain.name`).
-async fn toggle_entities(
+/// Handle one entity id for a power toggle: dispatch `[mikrotik]`
+/// devices to the RouterOS API path (or their HA relay), everything else
+/// to HA with `domain.name` validation.
+async fn toggle_one(
     ha: &HaClient,
     service: &'static str,
-    targets: &[Node],
-    extras: &[String],
-    extras_label: &'static str,
+    mikrotik: Option<&MikrotikConfig>,
+    entity: &str,
+    name_for_log: &str,
+    mikrotik_off: &mut Vec<String>,
 ) {
-    for node in targets {
-        if let Some(entity) = &node.entity {
-            if let Err(e) = split_entity(entity) {
-                warn!(
-                    "{}",
-                    fluent_i18n::t!("ha-invalid-entity", { "name" => &node.name, "entity" => entity, "error" => e.to_string() })
-                );
-                continue;
+    // Mikrotik devices are addressed by exact name — dispatch before any
+    // HA validation (a router name could otherwise parse as a fictional
+    // `mikrotik.<x>` HA entity).
+    if let Some(dev) = mikro::device(mikrotik, entity) {
+        if service == "turn_off" {
+            if !mikrotik_off.iter().any(|n| n == &dev.name) {
+                mikrotik_off.push(dev.name.clone());
             }
-            if let Err(e) = call(ha, entity, service).await {
+            info!(
+                "{}",
+                fluent_i18n::t!("power-off-mikrotik-skip", { "entity" => entity.to_string(), "address" => dev.address.clone() })
+            );
+            return;
+        }
+        if let Some(relay) = &dev.on_entity {
+            if let Err(e) = call(ha, relay, service).await {
                 warn!(
                     "{}",
-                    fluent_i18n::t!("ha-call-failed", { "service" => service, "entity" => entity, "error" => e.to_string() })
+                    fluent_i18n::t!("ha-extra-failed", { "service" => service, "extra" => relay.clone(), "label" => name_for_log.to_string(), "error" => e.to_string() })
                 );
             }
         } else {
             warn!(
                 "{}",
-                fluent_i18n::t!("ha-skip-no-entity", { "name" => &node.name })
+                fluent_i18n::t!("power-on-mikrotik-manual", { "name" => dev.name.clone() })
             );
         }
+        return;
     }
-    for extra in extras {
-        if let Err(e) = split_entity(extra) {
-            warn!(
-                "{}",
-                fluent_i18n::t!("ha-extra-invalid", { "label" => extras_label, "extra" => extra, "error" => e.to_string() })
-            );
-            continue;
-        }
-        if let Err(e) = call(ha, extra, service).await {
+    if let Err(e) = split_entity(entity) {
+        warn!(
+            "{}",
+            fluent_i18n::t!("ha-invalid-entity", { "name" => name_for_log.to_string(), "entity" => entity.to_string(), "error" => e.to_string() })
+        );
+        return;
+    }
+    if let Err(e) = call(ha, entity, service).await {
+        warn!(
+            "{}",
+            fluent_i18n::t!("ha-call-failed", { "service" => service, "entity" => entity.to_string(), "error" => e.to_string() })
+        );
+    }
+}
+
+/// Call HA `service` for each per-node entity, then each cluster-level
+/// `extra_entities`.  Entities that name a `[mikrotik]` device are NOT
+/// sent to HA: for `turn_off` their device names are returned (the caller
+/// does the RouterOS API shutdown afterwards); for `turn_on` an optional
+/// `on_entity` relay is toggled in HA instead, or a manual-power warning
+/// is logged.
+async fn toggle_entities(
+    ha: &HaClient,
+    service: &'static str,
+    mikrotik: Option<&MikrotikConfig>,
+    targets: &[Node],
+    extras: &[String],
+    extras_label: &'static str,
+) -> Vec<String> {
+    let mut mikrotik_off = Vec::new();
+    for node in targets {
+        if let Some(entity) = &node.entity {
+            toggle_one(ha, service, mikrotik, entity, &node.name, &mut mikrotik_off).await;
+        } else {
             warn!(
                 "{}",
-                fluent_i18n::t!("ha-extra-failed", { "service" => service, "extra" => extra, "label" => extras_label, "error" => e.to_string() })
+                fluent_i18n::t!("ha-skip-no-entity", { "name" => &node.name })
             );
         }
     }
+    for extra in extras {
+        toggle_one(
+            ha,
+            service,
+            mikrotik,
+            extra,
+            extras_label,
+            &mut mikrotik_off,
+        )
+        .await;
+    }
+    mikrotik_off
 }
 
 /// Dispatch to the right HA service.  Inlined because each call only
@@ -263,39 +296,8 @@ pub async fn power_switch(
     );
 
     if dry_run {
-        for node in &disable_nodes {
-            println!(
-                "{}",
-                fluent_i18n::t!("power-off-would", { "action" => format!("ssh {}@{} sudo shutdown now", config.ssh_user, node.ssh_ip), "target" => String::new() })
-            );
-            if let Some(entity) = &node.entity {
-                println!(
-                    "{}",
-                    fluent_i18n::t!("power-switch-would-off", { "entity" => entity })
-                );
-            }
-        }
-        for extra in &disable_extras {
-            println!(
-                "{}",
-                fluent_i18n::t!("power-switch-would-off-extra", { "extra" => extra })
-            );
-        }
-        for node in &to.nodes {
-            if let Some(entity) = &node.entity {
-                println!(
-                    "{}",
-                    fluent_i18n::t!("power-switch-would-on", { "entity" => entity })
-                );
-            }
-        }
-        for extra in &to.extra_entities {
-            let id = extra.entity().to_string();
-            println!(
-                "{}",
-                fluent_i18n::t!("power-switch-would-on-extra", { "extra" => &id })
-            );
-        }
+        mikro::dry_switch_off(config, &disable_nodes, &disable_extras);
+        mikro::dry_switch_on(config, &to.nodes, &extra_ids(to));
         return Ok(());
     }
 
@@ -344,15 +346,41 @@ pub async fn power_switch(
         // Routed through `toggle_entities` (which validates + logs failures)
         // instead of ad-hoc loops with `let _ =` (which swallowed errors)
         // and dead `if dry_run` branches (dry-run returned at the top).
-        toggle_entities(&ha, "turn_off", &disable_nodes, &disable_extras, "from").await;
+        let mikrotik_off = toggle_entities(
+            &ha,
+            "turn_off",
+            config.mikrotik.as_ref(),
+            &disable_nodes,
+            &disable_extras,
+            "from",
+        )
+        .await;
+
+        // RouterOS devices named above: graceful API shutdown (after HA).
+        mikro::shutdown_named(config, &mikrotik_off).await;
 
         info!(
             "{}",
             fluent_i18n::t!("power-switch-powering-on", { "nodes" => to.nodes.len(), "entities" => to.extra_entities.len(), "to" => &to.name })
         );
-        toggle_entities(&ha, "turn_on", &to.nodes, &extra_ids(to), "to").await;
+        let _ = toggle_entities(
+            &ha,
+            "turn_on",
+            config.mikrotik.as_ref(),
+            &to.nodes,
+            &extra_ids(to),
+            "to",
+        )
+        .await;
     } else {
         info!("{}", fluent_i18n::t!("power-switch-no-ha"));
+        // No HA, but mikrotik devices named in `from` still get the
+        // graceful API shutdown.
+        mikro::shutdown_named(
+            config,
+            &mikro::collect_off(config, &disable_nodes, &disable_extras),
+        )
+        .await;
     }
     Ok(())
 }

+ 203 - 0
src/power/mikro.rs

@@ -0,0 +1,203 @@
+//! `[mikrotik]` dispatch helpers for the power flow: entity-name lookup,
+//! dry-run lines, and the graceful RouterOS API shutdown loop.
+
+use tracing::warn;
+
+use crate::config::{Config, MikrotikConfig, MikrotikDevice, Node};
+
+/// Look up a `[mikrotik]` device by its entity-style name.
+pub fn device<'a>(
+    mikrotik: Option<&'a MikrotikConfig>,
+    entity: &str,
+) -> Option<&'a MikrotikDevice> {
+    mikrotik.and_then(|m| m.devices.iter().find(|d| d.name == entity))
+}
+
+/// Names of the `[mikrotik]` devices referenced by a target set's
+/// entities (node entities + cluster extras), in order, deduplicated.
+pub fn collect_off(config: &Config, targets: &[Node], extras: &[String]) -> Vec<String> {
+    let mut names = Vec::new();
+    let mut consider = |id: &str| {
+        if let Some(dev) = device(config.mikrotik.as_ref(), id)
+            && !names.contains(&dev.name)
+        {
+            names.push(dev.name.clone());
+        }
+    };
+    for node in targets {
+        if let Some(entity) = &node.entity {
+            consider(entity);
+        }
+    }
+    for id in extras {
+        consider(id);
+    }
+    names
+}
+
+/// Dry-run "power on" line for a mikrotik device: relay via HA, or a
+/// manual-plug reminder (the API cannot wake an off router).
+pub fn on_line(name: &str, relay: Option<&str>) {
+    match relay {
+        Some(r) => println!(
+            "{}",
+            fluent_i18n::t!("power-on-mikrotik-relay-would", { "name" => name.to_string(), "relay" => r.to_string() })
+        ),
+        None => println!(
+            "{}",
+            fluent_i18n::t!("power-on-mikrotik-manual-would", { "name" => name.to_string() })
+        ),
+    }
+}
+
+/// Graceful RouterOS API shutdown for each named device (warns on
+/// failure — a router that fails to shut down must not block the rest of
+/// the power flow).
+pub async fn shutdown_named(config: &Config, names: &[String]) {
+    for name in names {
+        if let Some(dev) = device(config.mikrotik.as_ref(), name)
+            && let Err(e) = crate::mikrotik::shutdown_device(dev).await
+        {
+            warn!(
+                "{}",
+                fluent_i18n::t!("power-off-mikrotik-failed", { "name" => &dev.name, "error" => e.to_string() })
+            );
+        }
+    }
+}
+
+/// Dry-run lines for `power off`: the ssh line per node plus the entity
+/// line (RouterOS API line for configured devices).
+pub fn dry_off(config: &Config, targets: &[Node], extras: &[String]) {
+    for node in targets {
+        println!(
+            "{}",
+            fluent_i18n::t!("power-off-would", { "action" => format!("ssh {}@{} sudo shutdown now", config.ssh_user, node.ssh_ip), "target" => String::new() })
+        );
+        if let Some(entity) = &node.entity {
+            match device(config.mikrotik.as_ref(), entity) {
+                Some(dev) => println!(
+                    "{}",
+                    fluent_i18n::t!("power-off-mikrotik-would", { "name" => &dev.name, "address" => &dev.address })
+                ),
+                None => println!(
+                    "{}",
+                    fluent_i18n::t!("power-off-entity-would", { "entity" => entity.to_string() })
+                ),
+            }
+        }
+    }
+    for id in extras {
+        match device(config.mikrotik.as_ref(), id) {
+            Some(dev) => println!(
+                "{}",
+                fluent_i18n::t!("power-off-mikrotik-would", { "name" => &dev.name, "address" => &dev.address })
+            ),
+            None => println!(
+                "{}",
+                fluent_i18n::t!("power-switch-would-off-extra", { "extra" => id.to_string() })
+            ),
+        }
+    }
+}
+
+/// Dry-run lines for `power on`: relay line for configured mikrotik
+/// devices (or the manual-plug reminder), HA lines for the rest.
+pub fn dry_on(config: &Config, targets: &[Node], extras: &[String]) {
+    for node in targets {
+        match node
+            .entity
+            .as_deref()
+            .and_then(|e| device(config.mikrotik.as_ref(), e))
+        {
+            Some(dev) => on_line(&dev.name, dev.on_entity.as_deref()),
+            None => {
+                if let Some(entity) = &node.entity {
+                    println!(
+                        "{}",
+                        fluent_i18n::t!("power-on-would-on", { "entity" => entity.to_string() })
+                    );
+                } else {
+                    println!(
+                        "{}",
+                        fluent_i18n::t!("power-on-would-skip", { "name" => node.name.clone() })
+                    );
+                }
+            }
+        }
+    }
+    for id in extras {
+        match device(config.mikrotik.as_ref(), id) {
+            Some(dev) => on_line(&dev.name, dev.on_entity.as_deref()),
+            None => println!(
+                "{}",
+                fluent_i18n::t!("power-on-would-on", { "entity" => id.to_string() })
+            ),
+        }
+    }
+}
+
+/// Dry-run "power off" phase of `power switch`: ssh + entity lines for
+/// the nodes leaving, plus the extras unique to `from`.
+pub fn dry_switch_off(config: &Config, nodes: &[Node], extras: &[String]) {
+    for node in nodes {
+        println!(
+            "{}",
+            fluent_i18n::t!("power-off-would", { "action" => format!("ssh {}@{} sudo shutdown now", config.ssh_user, node.ssh_ip), "target" => String::new() })
+        );
+        if let Some(entity) = &node.entity {
+            match device(config.mikrotik.as_ref(), entity) {
+                Some(dev) => println!(
+                    "{}",
+                    fluent_i18n::t!("power-off-mikrotik-would", { "name" => &dev.name, "address" => &dev.address })
+                ),
+                None => println!(
+                    "{}",
+                    fluent_i18n::t!("power-switch-would-off", { "entity" => entity.to_string() })
+                ),
+            }
+        }
+    }
+    for id in extras {
+        match device(config.mikrotik.as_ref(), id) {
+            Some(dev) => println!(
+                "{}",
+                fluent_i18n::t!("power-off-mikrotik-would", { "name" => &dev.name, "address" => &dev.address })
+            ),
+            None => println!(
+                "{}",
+                fluent_i18n::t!("power-switch-would-off-extra", { "extra" => id.to_string() })
+            ),
+        }
+    }
+}
+
+/// Dry-run "power on" phase of `power switch`.
+pub fn dry_switch_on(config: &Config, nodes: &[Node], extras: &[String]) {
+    for node in nodes {
+        match node
+            .entity
+            .as_deref()
+            .and_then(|e| device(config.mikrotik.as_ref(), e))
+        {
+            Some(dev) => on_line(&dev.name, dev.on_entity.as_deref()),
+            None => {
+                if let Some(entity) = &node.entity {
+                    println!(
+                        "{}",
+                        fluent_i18n::t!("power-switch-would-on", { "entity" => entity.to_string() })
+                    );
+                }
+            }
+        }
+    }
+    for id in extras {
+        match device(config.mikrotik.as_ref(), id) {
+            Some(dev) => on_line(&dev.name, dev.on_entity.as_deref()),
+            None => println!(
+                "{}",
+                fluent_i18n::t!("power-switch-would-on-extra", { "extra" => id.to_string() })
+            ),
+        }
+    }
+}

+ 29 - 0
src/power/tests.rs

@@ -145,3 +145,32 @@ fn cluster_without_extra_entities_is_empty() {
     let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
     assert!(c.extra_entities.is_empty());
 }
+
+#[test]
+fn mikrotik_dispatch_lookup() {
+    let cfg = cfg_with_ha();
+    // No [mikrotik] in this config — every entity stays on the HA path.
+    assert!(super::mikro::device(cfg.mikrotik.as_ref(), "switch.n1").is_none());
+
+    let toml = r#"
+ssh_user = "u"
+ssh_key_path = "/k"
+[[clusters]]
+name = "x"
+nodes = [{ name = "n1", ssh_ip = "10.0.0.1", rank = 0, entity = "router.lab" }]
+extra_entities = ["router.lab", "switch.plug"]
+[mikrotik]
+[[mikrotik.devices]]
+name = "router.lab"
+address = "192.168.88.1"
+username = "admin"
+password = "p"
+"#;
+    let cfg: crate::config::Config = toml::from_str(toml).unwrap();
+    let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
+    let off = super::mikro::collect_off(&cfg, &c.nodes, &super::extra_ids(&c));
+    assert_eq!(off, vec!["router.lab".to_string()]);
+    // same device referenced twice (node + extra) → listed once
+    let dev = super::mikro::device(cfg.mikrotik.as_ref(), "router.lab");
+    assert!(dev.is_some());
+}

+ 1 - 0
src/web/events.rs

@@ -230,6 +230,7 @@ mod tests {
             before_serve: std::collections::HashMap::new(),
             exec_aliases: std::collections::HashMap::new(),
             home_assistant: None,
+            mikrotik: None,
             global_mods_dir: None,
             drop_caches: true,
             mount_aliases: std::collections::HashMap::new(),

+ 1 - 0
src/web/tests.rs

@@ -40,6 +40,7 @@ pub(crate) fn fake_config() -> Config {
         before_serve: HashMap::new(),
         exec_aliases: HashMap::new(),
         home_assistant: None,
+        mikrotik: None,
         global_mods_dir: None,
         drop_caches: true,
         mount_aliases: HashMap::new(),

+ 1 - 0
src/web/topo.rs

@@ -176,6 +176,7 @@ mod tests {
             before_serve: HashMap::new(),
             exec_aliases: HashMap::new(),
             home_assistant: None,
+            mikrotik: None,
             global_mods_dir: None,
             drop_caches: true,
             mount_aliases: HashMap::new(),