mikrotik.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. //! Minimal RouterOS API client for graceful MikroTik power-off.
  2. //!
  3. //! Protocol per the official MikroTik "Python3 Example" template
  4. //! (help.mikrotik.com, updated 2026-02): plain TCP (default port 8728),
  5. //! variable-length word framing (no flags, no CRC encoding), sentences
  6. //! terminated by an empty word. Login sends `/login =name= =password=`;
  7. //! modern RouterOS answers `!done` outright, pre-6.43 answers
  8. //! `!done =ret=<hex>` and expects an MD5 challenge response.
  9. //!
  10. //! We only need one command: `/system/shutdown`.
  11. use anyhow::{Context, Result};
  12. use std::time::Duration;
  13. use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
  14. use tokio::net::TcpStream;
  15. use tracing::info;
  16. use crate::config::MikrotikDevice;
  17. /// Liveness probe for `health`: connect + login, nothing else.
  18. /// `Ok` = the router is up AND the configured credentials work;
  19. /// any failure (unreachable, timeout, rejected login) = the entity is off,
  20. /// exactly like an HA switch that does not answer.
  21. pub async fn ping_device(dev: &MikrotikDevice) -> Result<()> {
  22. let target = format!("{}:{}", dev.address, dev.api_port);
  23. let mut s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&target))
  24. .await
  25. .with_context(|| format!("connecting to RouterOS at {}", target))?
  26. .with_context(|| format!("connect to RouterOS at {}", target))?;
  27. s.set_nodelay(true)?;
  28. tokio::time::timeout(Duration::from_secs(10), login_stream(&mut s, dev))
  29. .await
  30. .map_err(|_| anyhow::anyhow!("RouterOS login timed out ({})", dev.name))?
  31. }
  32. /// Connect + login, then shut the router down via `/system/shutdown`.
  33. /// One connection per call — the router is about to die anyway.
  34. pub async fn shutdown_device(dev: &MikrotikDevice) -> Result<()> {
  35. let target = format!("{}:{}", dev.address, dev.api_port);
  36. let mut s = tokio::time::timeout(Duration::from_secs(10), TcpStream::connect(&target))
  37. .await
  38. .with_context(|| format!("connecting to RouterOS at {}", target))?
  39. .with_context(|| format!("connect to RouterOS at {}", target))?;
  40. s.set_nodelay(true)?;
  41. login_stream(&mut s, dev).await?;
  42. shutdown_stream(&mut s, dev).await
  43. }
  44. /// The login exchange on an authenticated-or-not stream: `/login` with
  45. /// username + password, then `!done` (modern) or the legacy MD5
  46. /// challenge round-trip (pre-6.43).
  47. async fn login_stream<S>(s: &mut S, dev: &MikrotikDevice) -> Result<()>
  48. where
  49. S: AsyncRead + AsyncWrite + Unpin,
  50. {
  51. send_sentence(
  52. s,
  53. &[
  54. "/login",
  55. &format!("=name={}", dev.username),
  56. &format!("=password={}", dev.password),
  57. ],
  58. )
  59. .await
  60. .context("sending /login")?;
  61. loop {
  62. let mut words = read_sentence(s).await.context("reading /login reply")?;
  63. if words.first().is_some_and(|w| w == "#routeros") {
  64. words.remove(0); // tolerate the legacy banner word
  65. }
  66. let rest: Vec<&str> = words.iter().skip(1).map(|w| w.as_str()).collect();
  67. match words.first().map(|s| s.as_str()) {
  68. Some("!trap") => {
  69. anyhow::bail!("RouterOS login rejected ({}): {}", dev.name, rest.join(" "))
  70. }
  71. Some("!done") => {
  72. let Some(hex) = rest.iter().find_map(|w| w.strip_prefix("=ret=")) else {
  73. return Ok(()); // no challenge — logged in
  74. };
  75. let challenge = hex_decode(hex)
  76. .map_err(|e| anyhow::anyhow!("login challenge is not hex: {hex} ({e})"))?;
  77. let mut pre = vec![0u8];
  78. pre.extend_from_slice(dev.password.as_bytes());
  79. pre.extend_from_slice(&challenge);
  80. let md = md5::compute(&pre);
  81. send_sentence(
  82. s,
  83. &[
  84. "/login",
  85. &format!("=name={}", dev.username),
  86. &format!("=response=00{}", hex_encode(md.as_slice())),
  87. ],
  88. )
  89. .await
  90. .context("sending login challenge response")?;
  91. }
  92. _ => anyhow::bail!(
  93. "unexpected RouterOS reply during login ({}): {}",
  94. dev.name,
  95. words.join(" ")
  96. ),
  97. }
  98. }
  99. }
  100. /// `/system/shutdown` on a logged-in stream; read replies until
  101. /// `!done` (`!trap` = the router refused, e.g. policy).
  102. async fn shutdown_stream<S>(s: &mut S, dev: &MikrotikDevice) -> Result<()>
  103. where
  104. S: AsyncRead + AsyncWrite + Unpin,
  105. {
  106. info!("RouterOS graceful shutdown: {}", dev.name);
  107. let r = tokio::time::timeout(Duration::from_secs(10), async {
  108. send_sentence(s, &["/system/shutdown"])
  109. .await
  110. .context("sending /system/shutdown")?;
  111. loop {
  112. let mut words = read_sentence(s).await.context("reading shutdown reply")?;
  113. if words.first().is_some_and(|w| w == "#routeros") {
  114. words.remove(0);
  115. }
  116. match words.first().map(|s| s.as_str()) {
  117. Some("!trap") => {
  118. anyhow::bail!(
  119. "RouterOS {} refused /system/shutdown: {}",
  120. dev.name,
  121. words
  122. .iter()
  123. .skip(1)
  124. .map(|w| w.as_str())
  125. .collect::<Vec<_>>()
  126. .join(" ")
  127. );
  128. }
  129. Some("!done") => break,
  130. _ => {} // interim `.*` progress lines
  131. }
  132. }
  133. Ok::<(), anyhow::Error>(())
  134. })
  135. .await;
  136. r.context("RouterOS shutdown timed out (10 s)")?
  137. }
  138. // --- Framing -------------------------------------------------------------
  139. //
  140. // Word = variable-length length prefix + raw UTF-8 bytes (no flags).
  141. // <0x80 1-byte length
  142. // 0x80..0xC0 (len | 0x8000), 2 bytes total
  143. // 0xC0..0xE0 (len | 0xC00000), 3 bytes total
  144. // 0xE0..0xF0 (len | 0xE0000000), 4 bytes total
  145. // 0xF0.. 5 bytes total, first is a flag only
  146. async fn write_word<W: AsyncWrite + Unpin>(w: &mut W, s: &str) -> std::io::Result<()> {
  147. let data = s.as_bytes();
  148. let len = data.len();
  149. let mut hdr = [0u8; 5];
  150. let n = if len < 0x80 {
  151. hdr[0] = len as u8;
  152. 1
  153. } else if len < 0x4000 {
  154. let v = (len | 0x8000) as u16;
  155. hdr[0..2].copy_from_slice(&v.to_be_bytes());
  156. 2
  157. } else if len < 0x200000 {
  158. let v = (len | 0xC00000) as u32;
  159. hdr[0..3].copy_from_slice(&v.to_be_bytes()[1..]);
  160. 3
  161. } else if len < 0x10000000 {
  162. let v = (len | 0xE0000000) as u32;
  163. hdr[0..4].copy_from_slice(&v.to_be_bytes());
  164. 4
  165. } else {
  166. hdr[0] = 0xF0;
  167. hdr[1..5].copy_from_slice(&len.to_be_bytes());
  168. 5
  169. };
  170. w.write_all(&hdr[..n]).await?;
  171. w.write_all(data).await?;
  172. Ok(())
  173. }
  174. async fn read_u8<R: AsyncRead + Unpin>(r: &mut R) -> std::io::Result<u32> {
  175. let mut x = [0u8; 1];
  176. r.read_exact(&mut x).await?;
  177. Ok(u32::from(x[0]))
  178. }
  179. async fn read_word_len<R: AsyncRead + Unpin>(r: &mut R) -> std::io::Result<u32> {
  180. let mut c = [0u8; 1];
  181. r.read_exact(&mut c).await?;
  182. Ok(if c[0] < 0x80 {
  183. u32::from(c[0])
  184. } else if c[0] < 0xC0 {
  185. ((c[0] & 0x3F) as u32) << 8 | read_u8(r).await?
  186. } else if c[0] < 0xE0 {
  187. ((c[0] & 0x1F) as u32) << 16 | read_u8(r).await? << 8 | read_u8(r).await?
  188. } else if c[0] < 0xF0 {
  189. ((c[0] & 0x0F) as u32) << 24
  190. | read_u8(r).await? << 16
  191. | read_u8(r).await? << 8
  192. | read_u8(r).await?
  193. } else {
  194. read_u8(r).await? << 24
  195. | read_u8(r).await? << 16
  196. | read_u8(r).await? << 8
  197. | read_u8(r).await?
  198. })
  199. }
  200. async fn read_word<R: AsyncRead + Unpin>(r: &mut R) -> std::io::Result<Vec<u8>> {
  201. let len = read_word_len(r).await?;
  202. let mut buf = vec![0u8; len as usize];
  203. r.read_exact(&mut buf).await?;
  204. Ok(buf)
  205. }
  206. /// One sentence = words + a terminating empty word.
  207. async fn send_sentence<W: AsyncWrite + Unpin>(w: &mut W, words: &[&str]) -> std::io::Result<()> {
  208. for word in words {
  209. write_word(w, word).await?;
  210. }
  211. write_word(w, "").await
  212. }
  213. async fn read_sentence<R: AsyncRead + Unpin>(r: &mut R) -> std::io::Result<Vec<String>> {
  214. let mut words = Vec::new();
  215. loop {
  216. let raw = read_word(r).await?;
  217. if raw.is_empty() {
  218. return Ok(words);
  219. }
  220. words.push(String::from_utf8_lossy(&raw).into_owned());
  221. }
  222. }
  223. fn hex_decode(s: &str) -> std::result::Result<Vec<u8>, String> {
  224. let bytes = s.as_bytes();
  225. if !bytes.len().is_multiple_of(2) {
  226. return Err("odd number of hex digits".to_string());
  227. }
  228. let val = |c: u8| -> std::result::Result<u8, String> {
  229. match c {
  230. b'0'..=b'9' => Ok(c - b'0'),
  231. b'a'..=b'f' => Ok(c - b'a' + 10),
  232. b'A'..=b'F' => Ok(c - b'A' + 10),
  233. _ => Err("not a hex digit".to_string()),
  234. }
  235. };
  236. (0..bytes.len())
  237. .step_by(2)
  238. .map(|i| Ok(val(bytes[i])? * 16 + val(bytes[i + 1])?))
  239. .collect()
  240. }
  241. fn hex_encode(bytes: &[u8]) -> String {
  242. let mut s = String::with_capacity(bytes.len() * 2);
  243. for b in bytes {
  244. s.push_str(&format!("{:02x}", b));
  245. }
  246. s
  247. }
  248. #[cfg(test)]
  249. mod tests {
  250. use super::*;
  251. use tokio::io::duplex;
  252. fn device() -> MikrotikDevice {
  253. MikrotikDevice {
  254. name: "t".into(),
  255. address: "127.0.0.1".into(),
  256. username: "admin".into(),
  257. password: "secret".into(),
  258. api_port: 8728,
  259. on_entity: None,
  260. }
  261. }
  262. #[tokio::test]
  263. async fn framing_roundtrip_all_bands() {
  264. for n in [0usize, 1, 127, 128, 16383, 16384, 2_097_151] {
  265. // duplex is a bounded pipe — write and read must run concurrently
  266. let (mut a, mut b) = duplex(64 * 1024);
  267. let s = "x".repeat(n);
  268. let s2 = s.clone();
  269. let writer = tokio::spawn(async move { write_word(&mut a, &s2).await });
  270. let reader = tokio::spawn(async move { read_word(&mut b).await });
  271. let got = reader.await.unwrap().unwrap();
  272. writer.await.unwrap().unwrap();
  273. assert_eq!(got.len(), n, "size {n}");
  274. assert_eq!(String::from_utf8(got).unwrap(), s);
  275. }
  276. }
  277. #[tokio::test]
  278. async fn framing_band5_roundtrip() {
  279. // 2 MiB+ payload exercises the 5-byte header band.
  280. let (mut a, mut b) = duplex(64 * 1024);
  281. let s = "y".repeat(2_100_000);
  282. let s2 = s.clone();
  283. let writer = tokio::spawn(async move { write_word(&mut a, &s2).await });
  284. let reader = tokio::spawn(async move { read_word(&mut b).await });
  285. let got = reader.await.unwrap().unwrap();
  286. writer.await.unwrap().unwrap();
  287. assert_eq!(got.len(), 2_100_000);
  288. }
  289. #[tokio::test]
  290. async fn sentence_terminates_on_empty_word() {
  291. let (mut a, mut b) = duplex(64 * 1024);
  292. tokio::spawn(async move {
  293. send_sentence(&mut a, &["/login", "=name=admin"])
  294. .await
  295. .unwrap();
  296. });
  297. let words = read_sentence(&mut b).await.unwrap();
  298. assert_eq!(words, vec!["/login", "=name=admin"]);
  299. }
  300. /// Client login exchange against a mock RouterOS that answers modern
  301. /// style: `!done` with no challenge.
  302. #[tokio::test]
  303. async fn login_modern_ok() {
  304. let (mut c, mut s) = duplex(64 * 1024);
  305. tokio::spawn(async move {
  306. let sent = read_sentence(&mut s).await.unwrap();
  307. assert_eq!(sent, vec!["/login", "=name=admin", "=password=secret"]);
  308. send_sentence(&mut s, &["!done"]).await.unwrap();
  309. });
  310. login_stream(&mut c, &device()).await.unwrap();
  311. }
  312. /// Legacy challenge: `!done =ret=<hex>` → client must answer with the
  313. /// MD5 response, then `!done`.
  314. #[tokio::test]
  315. async fn login_legacy_challenge() {
  316. let (mut c, mut s) = duplex(64 * 1024);
  317. tokio::spawn(async move {
  318. let sent = read_sentence(&mut s).await.unwrap();
  319. assert_eq!(sent[0], "/login");
  320. send_sentence(&mut s, &["!done", "=ret=deadbeef"])
  321. .await
  322. .unwrap();
  323. let resp = read_sentence(&mut s).await.unwrap();
  324. // expected: md5(0x00 || "secret" || 0xde 0xad 0xbe 0xef)
  325. let mut pre = vec![0u8];
  326. pre.extend_from_slice(b"secret");
  327. pre.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
  328. let md = md5::compute(&pre);
  329. let want = format!("=response=00{}", hex_encode(md.as_slice()));
  330. assert_eq!(
  331. resp,
  332. vec!["/login".to_string(), "=name=admin".to_string(), want],
  333. "challenge response"
  334. );
  335. send_sentence(&mut s, &["!done"]).await.unwrap();
  336. });
  337. login_stream(&mut c, &device()).await.unwrap();
  338. }
  339. #[tokio::test]
  340. async fn login_trap_fails() {
  341. let (mut c, mut s) = duplex(64 * 1024);
  342. tokio::spawn(async move {
  343. let _ = read_sentence(&mut s).await;
  344. send_sentence(&mut s, &["!trap", "=message=wrong pass"])
  345. .await
  346. .unwrap();
  347. });
  348. let e = login_stream(&mut c, &device()).await.unwrap_err();
  349. assert!(e.to_string().contains("wrong pass"), "{}", e);
  350. }
  351. #[tokio::test]
  352. async fn shutdown_done() {
  353. let (mut c, mut s) = duplex(64 * 1024);
  354. tokio::spawn(async move {
  355. let sent = read_sentence(&mut s).await.unwrap();
  356. assert_eq!(sent, vec!["/system/shutdown"]);
  357. send_sentence(&mut s, &["!done"]).await.unwrap();
  358. });
  359. shutdown_stream(&mut c, &device()).await.unwrap();
  360. }
  361. #[tokio::test]
  362. async fn shutdown_trap_fails() {
  363. let (mut c, mut s) = duplex(64 * 1024);
  364. tokio::spawn(async move {
  365. let _ = read_sentence(&mut s).await;
  366. send_sentence(&mut s, &["!trap", "=message=denied"])
  367. .await
  368. .unwrap();
  369. });
  370. let e = shutdown_stream(&mut c, &device()).await.unwrap_err();
  371. assert!(e.to_string().contains("denied"), "{}", e);
  372. }
  373. #[test]
  374. fn hex_roundtrip() {
  375. let bytes: Vec<u8> = vec![0xde, 0xad, 0x00, 0xff, 0x01];
  376. assert_eq!(hex_decode(&hex_encode(&bytes)).unwrap(), bytes);
  377. assert!(hex_decode("abc").is_err());
  378. assert!(hex_decode("zz").is_err());
  379. }
  380. }