From 5fbd72b370573a69ef93cff6b1c3ca7aa09d9186 Mon Sep 17 00:00:00 2001 From: Michal Humpula Date: Sun, 16 Mar 2025 10:20:48 +0100 Subject: [PATCH] working base --- .cargo/config.toml | 2 + .dockerignore | 9 + .gitignore | 1 + AGENTS.md | 12 + Cargo.lock | 910 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 21 + Dockerfile | 31 ++ README.md | 181 ++++++++ doc/ARCHITECTURE.md | 167 +++++++ doc/TESTING.md | 270 ++++++++++++ docker-compose.yml | 149 +++++++ route-switcher.service | 44 ++ scripts/setup-router.sh | 53 +++ scripts/test-failover.sh | 118 +++++ scripts/verify-setup.sh | 35 ++ src/main.rs | 226 ++++++++++ src/pinger.rs | 205 +++++++++ src/routing.rs | 340 +++++++++++++++ src/state_machine.rs | 487 +++++++++++++++++++++ 19 files changed, 3261 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 doc/ARCHITECTURE.md create mode 100644 doc/TESTING.md create mode 100644 docker-compose.yml create mode 100644 route-switcher.service create mode 100755 scripts/setup-router.sh create mode 100755 scripts/test-failover.sh create mode 100755 scripts/verify-setup.sh create mode 100644 src/main.rs create mode 100644 src/pinger.rs create mode 100644 src/routing.rs create mode 100644 src/state_machine.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..3c32d25 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.aarch64-unknown-linux-gnu] +linker = "aarch64-linux-gnu-gcc" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..762b6d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +target/ +Dockerfile +.dockerignore +scripts/ +.git/ +.gitignore +README.md +ARCHITECTURE.md +TESTING.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ed7bc77 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +Use code comments only when absolutely needed. Less comments the better. + +Documentation is contain in doc/ folder. + +For building the code, use docker/podman. Never build directly on the machine. + +Since the setup requires network manipulation, we need to execute docker-compose with sudo + +When testing code to compile and if it works in basic way, use +`sudo podman-compose exec route-switcher-dev -- cargo build` and +`sudo podman-compose exec route-switcher-dev -- cargo run` +Do not add extra vars to those commands, RUST_LOG=debug is already configured. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..50d525d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,910 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jiff" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4" +dependencies = [ + "anyhow", + "byteorder", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-route" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0800eae8638a299eaa67476e1c6b6692922273e0f7939fd188fc861c837b9cd2" +dependencies = [ + "anyhow", + "bitflags", + "byteorder", + "libc", + "log", + "netlink-packet-core", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-utils" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34" +dependencies = [ + "anyhow", + "byteorder", + "paste", + "thiserror", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "libc", + "log", +] + +[[package]] +name = "no-std-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pnet" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "682396b533413cc2e009fbb48aadf93619a149d3e57defba19ff50ce0201bd0d" +dependencies = [ + "ipnetwork", + "pnet_base", + "pnet_datalink", + "pnet_packet", + "pnet_sys", + "pnet_transport", +] + +[[package]] +name = "pnet_base" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc190d4067df16af3aba49b3b74c469e611cad6314676eaf1157f31aa0fb2f7" +dependencies = [ + "no-std-net", +] + +[[package]] +name = "pnet_datalink" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79e70ec0be163102a332e1d2d5586d362ad76b01cec86f830241f2b6452a7b7" +dependencies = [ + "ipnetwork", + "libc", + "pnet_base", + "pnet_sys", + "winapi", +] + +[[package]] +name = "pnet_macros" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13325ac86ee1a80a480b0bc8e3d30c25d133616112bb16e86f712dcf8a71c863" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "pnet_macros_support" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed67a952585d509dd0003049b1fc56b982ac665c8299b124b90ea2bdb3134ab" +dependencies = [ + "pnet_base", +] + +[[package]] +name = "pnet_packet" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c96ebadfab635fcc23036ba30a7d33a80c39e8461b8bd7dc7bb186acb96560f" +dependencies = [ + "glob", + "pnet_base", + "pnet_macros", + "pnet_macros_support", +] + +[[package]] +name = "pnet_sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d4643d3d4db6b08741050c2f3afa9a892c4244c085a72fcda93c9c2c9a00f4b" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "pnet_transport" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f604d98bc2a6591cf719b58d3203fd882bdd6bf1db696c4ac97978e9f4776bf" +dependencies = [ + "libc", + "pnet_base", + "pnet_packet", + "pnet_sys", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "route-switcher" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "clap", + "crossbeam-channel", + "env_logger", + "libc", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "pnet", + "pnet_sys", + "rand", + "signal-hook", + "tokio", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..71ef365 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "route-switcher" +version = "0.1.0" +edition = "2024" + +[dependencies] +pnet = "0.35.0" +pnet_sys = "0.35.0" +rand = "0.9.1" +log = "0.4.27" +env_logger = "0.11.8" +libc = "0.2.172" +signal-hook = "0.3.18" +crossbeam-channel = "0.5.15" +netlink-packet-route = "0.23.0" +netlink-packet-core = "0.7.0" +netlink-sys = "0.8.7" +anyhow = "1.0.98" +bytes = "1.10.1" +tokio = { version = "1.42", features = ["full"] } +clap = { version = "4.5", features = ["derive"] } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bcb3655 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM rust:1.93-alpine AS builder + +RUN apk add --no-cache musl-dev + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ + +# Create dummy main.rs to cache dependencies +RUN mkdir src && echo "fn main() {}" > src/main.rs +RUN cargo build --release && rm -rf src + +COPY src ./src +RUN cargo build --release + +FROM alpine:latest + +RUN apk add --no-cache iputils iptables + +WORKDIR /app + +COPY --from=builder /app/target/release/route-switcher . + +ENV RUST_LOG=info +ENV PRIMARY_INTERFACE=eth0 +ENV SECONDARY_INTERFACE=eth1 +ENV PRIMARY_GATEWAY=192.168.1.1 +ENV SECONDARY_GATEWAY=192.168.2.1 +ENV PING_TARGET=8.8.8.8 + +CMD ["./route-switcher"] diff --git a/README.md b/README.md index e69de29..d9503c4 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,181 @@ +# Route-Switcher + +A Linux-based network failover system written in Rust that automatically switches routing between dual network interfaces based on connectivity monitoring. + +## Overview + +Route-Switcher monitors connectivity to specified IP addresses via multiple network interfaces and automatically manages routing tables to ensure network redundancy. When the primary interface fails, it seamlessly switches to a secondary interface, and automatically fails back when the primary connection is restored. + +## Architecture + +### Core Components + +1. **Async Pingers** (`src/pinger.rs`) + - Dual-interface ICMP monitoring + - Explicit interface binding (equivalent to `ping -I `) + - Configurable ping targets and intervals + - Async/await implementation with tokio + +2. **Route Manager** (`src/routing.rs`) + - Netlink-based route manipulation + - No external dependencies on `ip` command + - Route addition and deletion + - Metric-based route prioritization + +3. **State Machine** (`src/main.rs`) + - Failover logic with anti-flapping protection + - Three consecutive failures trigger failover + - One minute of stable connectivity triggers failback + - Prevents switching when both interfaces fail + +4. **Configuration** + - Interface definitions (primary/secondary) + - Gateway configurations + - Ping targets and timing + - Route metrics + +## Key Features + +- **Dual Interface Monitoring**: Simultaneous ping testing via both network interfaces +- **Automatic Failover**: Switches to secondary interface after 3 consecutive ping failures +- **Smart Failback**: Returns to primary interface after 1 minute of stable connectivity +- **Anti-Flapping**: Prevents frequent switching between interfaces +- **Edge Case Handling**: Won't switch if secondary interface is also down +- **Netlink Integration**: Direct kernel communication for route management +- **Async Architecture**: Non-blocking monitoring and management + +## Requirements + +- Linux operating system +- Rust 2024 edition +- Two network interfaces with internet connectivity +- Root privileges for route manipulation +- Netlink kernel support + +## Configuration + +### Network Setup Example +```bash +# Primary interface +eth0: 192.168.1.10/24, gateway 192.168.1.1 + +# Secondary interface +eth1: 192.168.2.10/24, gateway 192.168.2.1 +``` + +### Application Configuration +The application is configured through command-line arguments: + +```bash +sudo cargo run -- \ + --primary-interface eth0 \ + --secondary-interface eth1 \ + --primary-gateway 192.168.1.1 \ + --secondary-gateway 192.168.2.1 \ + --ping-target 8.8.8.8 +``` + +## Usage + +### Basic Usage +```bash +# Run with default settings +sudo cargo run + +# Run with custom configuration +sudo cargo run -- \ + --primary-interface eth0 \ + --secondary-interface eth1 \ + --primary-gateway 192.168.1.1 \ + --secondary-gateway 192.168.2.1 +``` + +### Development +```bash +# Build +cargo build + +# Run tests +cargo test + +# Run with debug logging +RUST_LOG=debug sudo cargo run + +# Run with custom log level +RUST_LOG=info sudo cargo run +``` + +## Testing Environment + +### Podman-Compose Setup +The project includes a complete testing environment using podman-compose: + +```bash +# Start test environment +podman-compose up -d + +# View logs +podman-compose logs -f route-switcher + +# Stop test environment +podman-compose down +``` + +### End-to-End Testing +```bash +# Simulate primary interface failure +podman-compose exec primary ip link set eth0 down + +# Observe failover in logs +podman-compose logs -f route-switcher + +# Restore primary interface +podman-compose exec primary ip link set eth0 up + +# Observe failback after 1 minute +``` + +## Implementation Details + +### State Machine +``` +[Boot] -> [Primary] (after initial connectivity check) +[Primary] -> [Fallback] (after 3 consecutive failures) +[Fallback] -> [Primary] (after 60 seconds of stability) +``` + +### Route Management +- Primary route: `ip r add default via dev metric 10` +- Secondary route: `ip r add default via dev metric 20` +- Routes are managed via netlink, not external commands + +### Failover Logic +1. **Detection**: 3 consecutive ping failures on primary interface +2. **Verification**: Secondary interface must be responsive +3. **Switch**: Update routing table to use secondary gateway +4. **Monitor**: Continue monitoring both interfaces +5. **Recovery**: After 60 seconds of stable primary connectivity, switch back + +### Error Handling +- Graceful degradation on interface failures +- Comprehensive logging for debugging +- Signal handling for clean shutdown +- Recovery from temporary network issues + +## Dependencies + +- `tokio` - Async runtime +- `pnet` - Packet networking +- `netlink-sys` - Netlink kernel communication +- `anyhow` - Error handling +- `log` + `env_logger` - Logging +- `crossbeam-channel` - Inter-thread communication +- `signal-hook` - Signal handling + +## Development Phases + +- [ ] End-to-end automated tests + +## License + +GPLv3 \ No newline at end of file diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md new file mode 100644 index 0000000..d18e123 --- /dev/null +++ b/doc/ARCHITECTURE.md @@ -0,0 +1,167 @@ +# Architecture Documentation + +## System Overview + +Route-Switcher is a network failover system that operates at the application layer to provide automatic network redundancy. The system monitors network connectivity through multiple interfaces and manages routing tables to ensure continuous connectivity. + +## Component Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Main Thread │ │ Async Pingers │ │ Route Manager │ +│ │ │ │ │ │ +│ • State Machine │◄──►│ • Interface A │◄──►│ • Netlink API │ +│ • Decision Logic│ │ • Interface B │ │ • Route Add/Del │ +│ • Coordination │ │ • ICMP Monitoring│ │ • Metric Mgmt │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + │ │ │ + └───────────────────────┼───────────────────────┘ + │ + ┌──────────────────┐ + │ Linux Kernel │ + │ │ + │ • Routing Table │ + │ • Network Stack │ + │ • Netlink Socket │ + └──────────────────┘ +``` + +## Data Flow + +1. **Monitoring Phase** + - Async pingers send ICMP packets via both interfaces + - Results are collected and sent to main thread + - State machine evaluates connectivity patterns + +2. **Decision Phase** + - State machine determines if failover is needed + - Verifies secondary interface health + - Triggers route changes if conditions are met + +3. **Action Phase** + - Route manager updates kernel routing table + - Changes are applied via netlink interface + - System continues monitoring in new state + +## State Machine Design + +### States +- **Boot**: Initial state, gathering connectivity data +- **Primary**: Using primary interface for routing +- **Fallback**: Using secondary interface for routing + +### Transitions +``` +Boot → Primary: After 10 seconds of sampling (regardless of ping results) +Primary → Fallback: After 3 consecutive failures AND secondary is healthy +Fallback → Primary: After 60 seconds of stable primary connectivity +``` + +### Routing Behavior +- **Boot State**: Both routes are set up initially - primary (metric 10) and secondary (metric 20) +- **Primary State**: Primary route (metric 10) and secondary route (metric 20) present +- **Fallback State**: All three routes present - primary (metric 10), secondary (metric 20), and failover secondary (metric 5) +- **Exit**: Only the failover route (metric 5) is removed + +### Route Management Strategy +The system follows a "both routes always present, extra failover on-demand" approach: +1. **Initialization**: Set up primary route (metric 10) and secondary route (metric 20) +2. **Boot Phase**: Collect 10 seconds of ping samples to establish baseline connectivity +3. **Normal Operation**: Primary route serves traffic (metric 10), secondary available as backup (metric 20) +4. **Failover**: Add extra secondary route with highest priority (metric 5) for immediate failover +5. **Failback**: Remove extra failover route when primary recovers +6. **Cleanup**: Only remove the extra failover route on exit, preserving base routes + +### State Persistence +- Current state is maintained in memory +- State changes are logged for debugging +- No persistent storage required (state rebuilds on restart) + +## Interface Design + +### Pinger Interface +```rust +pub trait Pinger { + async fn ping(&self, target: Ipv4Addr, interface: &str) -> PingResult; + async fn start_monitoring(&self, targets: &[Ipv4Addr], interfaces: &[String]) -> Receiver; +} +``` + +### Route Manager Interface +```rust +pub trait RouteManager { + fn add_default_route(&self, gateway: Ipv4Addr, interface: &str, metric: u32) -> Result<()>; + fn delete_default_route(&self, gateway: Ipv4Addr, interface: &str, metric: u32) -> Result<()>; + fn get_current_routes(&self) -> Result>; +} +``` + +## Threading Model + +### Main Thread +- Runs the state machine +- Handles signals and graceful shutdown +- Coordinates between components + +### Async Pinger Tasks +- One task per interface +- Non-blocking ICMP operations +- Results sent via channels + +### Route Manager +- Synchronous operations (netlink is sync) +- Called from main thread +- Thread-safe operations + +## Error Handling Strategy + +### Categories +1. **Network Errors**: Temporary connectivity issues +2. **System Errors**: Permission problems, interface not found +3. **Configuration Errors**: Invalid IP addresses, missing interfaces + +### Recovery Mechanisms +- **Network Errors**: Retry with exponential backoff +- **System Errors**: Log and exit (requires admin intervention) +- **Configuration Errors**: Validate on startup, exit if invalid + +## Security Considerations + +### Privileges +- Requires root privileges for route manipulation +- Drops unnecessary privileges where possible +- Validates all user inputs + +### Network Security +- Only sends ICMP packets to configured targets +- No arbitrary packet crafting +- Interface binding prevents traffic leakage + +## Performance Characteristics + +### Resource Usage +- **Memory**: Minimal (~10MB) +- **CPU**: Low (periodic ICMP packets) +- **Network**: Very low (only ping traffic) + +### Scalability +- Single target machine design +- Supports multiple ping targets +- Limited to 2 interfaces (current design) + +## Testing Architecture + +### Unit Tests +- Individual component testing +- Mock network interfaces +- State machine logic verification + +### Integration Tests +- Component interaction testing +- Real network interface usage +- Netlink operation verification + +### End-to-End Tests +- Full system testing in containers +- Network failure simulation +- Failover timing verification diff --git a/doc/TESTING.md b/doc/TESTING.md new file mode 100644 index 0000000..48c21b3 --- /dev/null +++ b/doc/TESTING.md @@ -0,0 +1,270 @@ +# Testing Guide + +## Overview + +This document describes the testing strategy and environment for the Route-Switcher project. + +## Testing Environment + +### Podman-Compose Setup + +The testing environment uses podman-compose to create a realistic network topology with routers and a single ICMP target: + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Route-Switcher │ │ Primary Router│ │ │ +│ │ │ │ │ │ +│ eth0 ────────────┼────►│ eth0 ──────────┼────►│ ICMP Target │ +│ eth1 ────────────┼────►│ eth1 ──────────┼────►│ │ +│ │ │ │ │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + │ │ │ + ▼ ▼ ▼ +primary-net secondary-net target-net +192.168.1.0/24 192.168.2.0/24 10.0.0.0/24 +``` + +### Container Architecture + +- **route-switcher**: Dual interfaces (eth0→primary-net, eth1→secondary-net) +- **primary-router**: Connects primary-net ↔ target-net (192.168.1.1 ↔ 10.0.0.1) +- **secondary-router**: Connects secondary-net ↔ target-net (192.168.2.1 ↔ 10.0.0.2) +- **icmp-target**: Single IP on target-net (10.0.0.100), reachable via either router + +### Quick Start + +```bash +# Start the testing environment +podman-compose up -d + +# Run automated failover test +./scripts/test-failover.sh + +# View logs +podman-compose logs -f route-switcher + +# Stop environment +podman-compose down +``` + +### Network Configuration + +**Route-Switcher:** +- eth0: 192.168.1.10 (primary network) +- eth1: 192.168.2.10 (secondary network) +- Default gateway: 192.168.1.1 (primary router) + +**Primary Router:** +- eth0: 192.168.1.1 (primary network) +- eth1: 10.0.0.1 (target network) +- Routes traffic between networks with NAT + +**Secondary Router:** +- eth0: 192.168.2.1 (secondary network) +- eth1: 10.0.0.2 (target network) +- Routes traffic between networks with NAT + +**ICMP Target:** +- Single IP: 10.0.0.100 +- Default route: 10.0.0.1 (primary router) +- Responds to ping from both routers + +## Test Scenarios + +### 1. Basic Connectivity Test +**Objective**: Verify basic ping functionality on both interfaces + +```bash +# Start environment +podman-compose up -d + +# Test primary connectivity +podman-compose exec route-switcher ping -c 3 -I eth0 10.0.0.100 + +# Test secondary connectivity +podman-compose exec route-switcher ping -c 3 -I eth1 10.0.0.100 + +# Check routing table +podman-compose exec route-switcher ip route show +``` + +### 2. Failover Test +**Objective**: Verify automatic failover when primary router fails + +```bash +# Start monitoring logs +podman-compose logs -f route-switcher & + +# Simulate primary router failure +podman-compose exec primary-router ip link set eth0 down + +# Verify failover occurs (should see in logs) +# Wait for state change to Fallback + +# Check routing table after failover +podman-compose exec route-switcher ip route show + +# Test connectivity via secondary router +podman-compose exec route-switcher ping -c 3 10.0.0.100 + +# Restore primary router +podman-compose exec primary-router ip link set eth0 up + +# Verify failback after 60 seconds +``` + +### 3. Dual Failure Test +**Objective**: Verify system doesn't failover when both routers fail + +```bash +# Start monitoring logs +podman-compose logs -f route-switcher & + +# Fail both routers +podman-compose exec primary-router ip link set eth0 down +podman-compose exec secondary-router ip link set eth0 down + +# Verify no routing changes occur +# System should remain in current state + +# Restore routers +podman-compose exec primary-router ip link set eth0 up +podman-compose exec secondary-router ip link set eth0 up +``` + +### 4. Router Target Interface Failure +**Objective**: Test upstream network failure simulation + +```bash +# Fail primary router's connection to target network +podman-compose exec primary-router ip link set eth1 down + +# Should trigger failover to secondary router +# Verify connectivity still works via secondary path + +# Restore primary router's target connection +podman-compose exec primary-router ip link set eth1 up +``` + +### 5. Automated Failover Test +**Objective**: Run complete automated test sequence + +```bash +# Run the comprehensive test script +./scripts/test-failover.sh + +# This script will: +# 1. Start the environment +# 2. Verify initial connectivity +# 3. Simulate primary router failure +# 4. Monitor failover +# 5. Restore primary router +# 6. Verify failback after 60 seconds +``` + +## Unit Tests + +### Running Tests +```bash +# Run all tests +cargo test + +# Run specific test module +cargo test pinger + +# Run with coverage +cargo tarpaulin --out Html +``` + +### Test Structure +``` +tests/ +├── unit/ +│ ├── pinger_tests.rs +│ ├── routing_tests.rs +│ └── state_machine_tests.rs +├── integration/ +│ ├── netlink_tests.rs +│ └── dual_interface_tests.rs +└── e2e/ + └── failover_tests.rs +``` + +## Performance Testing + +### Load Testing +```bash +# Test with multiple ping targets +cargo run -- --ping-target 8.8.8.8 + +# Monitor resource usage +podman stats route-switcher + +# Test long-running stability +# Run for 24 hours and monitor for memory leaks +``` + +### Network Latency Testing +```bash +# Measure failover time +# Start script to time the state transition +start_time=$(date +%s%N) +# Trigger failure +# Wait for state change +end_time=$(date +%s%N) +failover_time=$((($end_time - $start_time) / 1000000)) +echo "Failover time: ${failover_time}ms" +``` + +## Debugging Tests + +### Common Issues +1. **Permission Denied**: Ensure containers run with privileged mode +2. **Interface Not Found**: Check network configuration in compose file +3. **Netlink Errors**: Verify kernel supports required operations +4. **Timing Issues**: Adjust test timeouts for your environment + +### Debug Commands +```bash +# Check container network interfaces +podman-compose exec route-switcher ip addr show + +# Check routing table +podman-compose exec route-switcher ip route show + +# Monitor network traffic +podman-compose exec route-switcher tcpdump -i any icmp + +# Check system logs +podman-compose exec route-switcher dmesg | tail -20 +``` + +## Test Data + +### Sample Ping Results +```rust +// Mock data for testing +let mock_ping_results = vec![ + PingResult::Ok, // Normal operation + PingResult::Failed, // Single failure + PingResult::Failed, // Consecutive failure + PingResult::Failed, // Trigger failover +]; +``` + +### Network Configuration +```bash +# Test network setup +ip addr add 192.168.1.10/24 dev eth0 +ip addr add 192.168.2.10/24 dev eth1 +ip route add default via 192.168.1.1 dev eth0 metric 10 +ip route add default via 192.168.2.1 dev eth1 metric 20 +``` + +## Test Coverage Goals + +- **Unit Tests**: 90%+ code coverage +- **Integration Tests**: All major component interactions +- **E2E Tests**: All user scenarios and edge cases +- **Performance Tests**: Resource usage and timing validation diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..13748ec --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,149 @@ +version: '3.8' + +services: + route-switcher: + build: . + privileged: true + depends_on: + - primary-router + - secondary-router + - icmp-target + networks: + primary-net: + ipv4_address: 192.168.200.10 + secondary-net: + ipv4_address: 192.168.201.10 + environment: + - RUST_LOG=debug + - PRIMARY_INTERFACE=eth0 + - SECONDARY_INTERFACE=eth1 + - PRIMARY_GATEWAY=192.168.200.11 + - SECONDARY_GATEWAY=192.168.201.11 + - PING_TARGET=192.168.202.100 + cap_add: + - NET_ADMIN + - SYS_ADMIN + volumes: + - ./scripts:/scripts:ro + + route-switcher-dev: + image: rust:1.93-bullseye + privileged: true + working_dir: /app + command: | + sh -c " + echo nameserver 192.168.10.1 > /etc/resolv.conf && + /bin/sleep infinity + " + networks: + primary-net: + ipv4_address: 192.168.200.20 + secondary-net: + ipv4_address: 192.168.201.20 + default: + dns: + - 192.168.10.1 + environment: + - RUST_LOG=debug + - PRIMARY_INTERFACE=eth0 + - SECONDARY_INTERFACE=eth1 + - PRIMARY_GATEWAY=192.168.200.11 + - SECONDARY_GATEWAY=192.168.201.11 + - PING_TARGET=192.168.202.100 + cap_add: + - NET_ADMIN + - SYS_ADMIN + volumes: + - .:/app:rw + - cargo-cache:/usr/local/cargo/registry + + primary-router: + image: alpine:latest + privileged: true + networks: + primary-net: + ipv4_address: 192.168.200.11 + target-net: + ipv4_address: 192.168.202.11 + default: + dns: + - 192.168.10.1 + cap_add: + - NET_ADMIN + - SYS_ADMIN + command: sh /scripts/setup-router.sh primary + volumes: + - ./scripts:/scripts:ro + + secondary-router: + image: alpine:latest + privileged: true + networks: + secondary-net: + ipv4_address: 192.168.201.11 + target-net: + ipv4_address: 192.168.202.12 + default: + dns: + - 192.168.10.1 + cap_add: + - NET_ADMIN + - SYS_ADMIN + command: sh /scripts/setup-router.sh secondary + volumes: + - ./scripts:/scripts:ro + + icmp-target: + image: alpine:latest + networks: + target-net: + ipv4_address: 192.168.202.100 + default: + dns: + - 192.168.10.1 + cap_add: + - NET_ADMIN + command: | + sh -c " + echo 'ICMP Target configured at 192.168.202.100' && + echo 'Routes:' && + ip route show && + while true; do + echo $(date): ICMP Target alive + sleep 30 + done + " + healthcheck: + test: ["CMD", "ping", "-c", "1", "192.168.202.11"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + primary-net: + driver: bridge + internal: true + ipam: + config: + - subnet: 192.168.200.0/24 + + secondary-net: + driver: bridge + internal: true + ipam: + config: + - subnet: 192.168.201.0/24 + + target-net: + driver: bridge + internal: true + ipam: + config: + - subnet: 192.168.202.0/24 + + default: + external: true + name: podman + +volumes: + cargo-cache: diff --git a/route-switcher.service b/route-switcher.service new file mode 100644 index 0000000..09904af --- /dev/null +++ b/route-switcher.service @@ -0,0 +1,44 @@ +[Unit] +Description=Route-Switcher - Automatic network failover service +Documentation=https://git.hudrydum.cz/michal.humpula/route-switcher +After=network.target network-online.target +Wants=network-online.target +ConditionPathExists=/usr/local/sbin/route-switcher + +[Service] +Type=simple +ExecStart=/usr/local/sbin/route-switcher +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +# Environment variables (customize these for your network) +Environment=RUST_LOG=info +Environment=PRIMARY_INTERFACE=eth0 +Environment=SECONDARY_INTERFACE=eth1 +Environment=PRIMARY_GATEWAY=192.168.1.1 +Environment=SECONDARY_GATEWAY=192.168.2.1 +Environment=PING_TARGET=8.8.8.8 + +User=root +Group=root +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW +NoNewPrivileges=true + +# Resource limits +LimitNOFILE=65536 +LimitNPROC=4096 + +# Process isolation +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/log +PrivateTmp=true +ProtectKernelTunables=true +ProtectControlGroups=true +RestrictRealtime=true + +[Install] +WantedBy=multi-user.target diff --git a/scripts/setup-router.sh b/scripts/setup-router.sh new file mode 100755 index 0000000..4896e81 --- /dev/null +++ b/scripts/setup-router.sh @@ -0,0 +1,53 @@ +#!/bin/sh + +set -e + +ROUTER_TYPE="$1" + +echo "Setting up $ROUTER_TYPE router..." + +# fix dns +echo "nameserver 192.168.10.1" > /etc/resolv.conf + +apk add --no-cache iputils iptables + +# Enable IP forwarding +echo 1 > /proc/sys/net/ipv4/ip_forward +sysctl -w net.ipv4.ip_forward=1 + +if [ "$ROUTER_TYPE" = "primary" ]; then + echo "Configuring PRIMARY router (192.168.200.11 192.168.202.11 172.17.0.2)" + + ip addr show + echo "Routes:" + ip route show + + # NAT for traffic from primary network to target network + iptables -t nat -A POSTROUTING -s 192.168.200.0/24 -d 192.168.202.0/24 -j MASQUERADE + iptables -P FORWARD ACCEPT + +elif [ "$ROUTER_TYPE" = "secondary" ]; then + echo "Configuring SECONDARY router (192.168.201.11 ↔ 192.168.202.12 ↔ 172.17.0.3)" + + ip addr show + echo "Routes:" + ip route show + + # NAT for traffic from secondary network to target network + iptables -t nat -A POSTROUTING -s 192.168.201.0/24 -d 192.168.202.0/24 -j MASQUERADE + iptables -P FORWARD ACCEPT +else + echo "Error: Invalid router type. Use 'primary' or 'secondary'" + exit 1 +fi + +echo "Secondary router setup complete" +echo "NAT rules:" +iptables -t nat -L POSTROUTING -n -v + +# Keep container running +echo "Router is running. Monitoring interfaces..." +while true; do + echo "$(date): Router $ROUTER_TYPE status - interfaces up" + sleep 60 +done diff --git a/scripts/test-failover.sh b/scripts/test-failover.sh new file mode 100755 index 0000000..d1bb953 --- /dev/null +++ b/scripts/test-failover.sh @@ -0,0 +1,118 @@ +#!/bin/bash + +set -e + +echo "=== Route-Switcher Failover Test Script ===" +echo + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if podman-compose is available +if ! command -v podman-compose &> /dev/null; then + print_error "podman-compose is not installed or not in PATH" + exit 1 +fi + +# Start the environment +print_status "Starting test environment..." +podman-compose up -d + +# Wait for containers to be ready +print_status "Waiting for containers to initialize..." +sleep 10 + +# Check if all containers are running +print_status "Checking container status..." +podman-compose ps + +# Set up initial default route for route-switcher (testing scenario only) +# In production, this would be configured by the network admin +print_status "Setting up initial default route via primary router..." +podman-compose exec route-switcher ip route add default via 192.168.200.11 dev eth0 + +# Verify network connectivity +print_status "Testing initial connectivity..." + +# Test from route-switcher to target (should use default route via primary) +print_status "Testing connectivity via primary router..." +podman-compose exec route-switcher ping -c 3 192.168.202.100 || { + print_error "Primary connectivity test failed" + podman-compose down + exit 1 +} + +# Test specific interface connectivity +print_status "Testing connectivity via secondary interface..." +podman-compose exec route-switcher ping -c 3 -I eth1 192.168.202.100 || { + print_warning "Secondary interface connectivity test failed (might be expected initially)" +} + +print_status "Initial connectivity tests passed!" + +# Show current routing table +print_status "Current routing table in route-switcher:" +podman-compose exec route-switcher ip route show + +echo +print_warning "=== Starting Failover Test ===" +print_status "Monitoring route-switcher logs (press Ctrl+C to stop monitoring)..." +echo + +# Start monitoring logs in background +podman-compose logs -f route-switcher & +LOGS_PID=$! + +# Wait a bit for initial logs +sleep 5 + +print_status "Simulating primary router failure by shutting down eth0..." +podman-compose exec primary-router ip link set eth0 down + +print_status "Waiting for failover to occur..." +sleep 15 + +# Check if failover happened +print_status "Checking routing table after primary failure..." +podman-compose exec route-switcher ip route show + +print_status "Testing connectivity after failover..." +podman-compose exec route-switcher ping -c 3 192.168.202.100 || { + print_warning "Connectivity test after failover failed (this might be expected during transition)" +} + +print_status "Restoring primary router..." +podman-compose exec primary-router ip link set eth0 up + +print_status "Waiting for failback (should take ~60 seconds of stable connection)..." +sleep 70 + +print_status "Final routing table check:" +podman-compose exec route-switcher ip route show + +print_status "Final connectivity test:" +podman-compose exec route-switcher ping -c 3 192.168.202.100 + +# Stop monitoring logs +kill $LOGS_PID 2>/dev/null || true + +echo +print_status "=== Test Complete ===" +print_status "To stop the environment: podman-compose down" +print_status "To view logs: podman-compose logs route-switcher" diff --git a/scripts/verify-setup.sh b/scripts/verify-setup.sh new file mode 100755 index 0000000..7c77ed5 --- /dev/null +++ b/scripts/verify-setup.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -e + +echo "=== Quick Setup Verification ===" + +# Start containers +echo "Starting containers..." +podman-compose up -d + +# Wait for initialization +echo "Waiting for containers..." +sleep 10 + +# Check status +echo "Container status:" +podman-compose ps + +# Set up initial route +echo "Setting up initial default route..." +podman-compose exec route-switcher ip route add default via 192.168.200.11 dev eth0 + +# Show routing table +echo "Route-switcher routing table:" +podman-compose exec route-switcher ip route show + +# Test basic connectivity +echo "Testing connectivity to target..." +if podman-compose exec route-switcher ping -c 2 192.168.202.100; then + echo "✓ Connectivity test passed" +else + echo "✗ Connectivity test failed" +fi + +echo "Setup complete. Use './scripts/test-failover.sh' for full failover testing." diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c6e7cb4 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,226 @@ +use anyhow::Result; +use clap::Parser; +use env_logger::{Builder, Env}; +use log::{debug, error, info}; +use signal_hook::consts::signal::*; +use signal_hook::flag as signal_flag; +use std::net::Ipv4Addr; +use std::panic; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use tokio::signal; +use tokio::sync::broadcast; + +mod pinger; +mod routing; +mod state_machine; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Config { + /// Primary network interface + #[arg(long, default_value = "eth0")] + primary_interface: String, + + /// Secondary network interface + #[arg(long, default_value = "eth1")] + secondary_interface: String, + + /// Primary gateway IP + #[arg(long, default_value = "192.168.1.1")] + primary_gateway: String, + + /// Secondary gateway IP + #[arg(long, default_value = "192.168.2.1")] + secondary_gateway: String, + + /// Ping target IP + #[arg(long, default_value = "8.8.8.8")] + ping_target: String, + + /// Ping interval in seconds + #[arg(long, default_value = "1")] + ping_interval: u64, + + /// Failover threshold (consecutive failures) + #[arg(long, default_value = "3")] + failover_threshold: usize, + + /// Failback delay in seconds + #[arg(long, default_value = "60")] + failback_delay: u64, +} + +#[tokio::main] +async fn main() -> Result<()> { + let env = Env::default().filter_or("RUST_LOG", "info"); + let mut builder = Builder::from_env(env); + builder.init(); + info!("Starting Route-Switcher..."); + + let config = Config::parse(); + + // Override with environment variables if present + let primary_interface = + std::env::var("PRIMARY_INTERFACE").unwrap_or(config.primary_interface.clone()); + let secondary_interface = + std::env::var("SECONDARY_INTERFACE").unwrap_or(config.secondary_interface.clone()); + let primary_gateway = + std::env::var("PRIMARY_GATEWAY").unwrap_or(config.primary_gateway.clone()); + let secondary_gateway = + std::env::var("SECONDARY_GATEWAY").unwrap_or(config.secondary_gateway.clone()); + let ping_target = std::env::var("PING_TARGET").unwrap_or(config.ping_target.clone()); + + let mut config_with_env = config; + config_with_env.primary_interface = primary_interface; + config_with_env.secondary_interface = secondary_interface; + config_with_env.primary_gateway = primary_gateway; + config_with_env.secondary_gateway = secondary_gateway; + config_with_env.ping_target = ping_target; + + debug!("Configuration: {:?}", config_with_env); + + // Validate configuration + let primary_gateway: Ipv4Addr = config_with_env + .primary_gateway + .parse() + .map_err(|e| anyhow::anyhow!("Invalid primary gateway: {}", e))?; + let secondary_gateway: Ipv4Addr = config_with_env + .secondary_gateway + .parse() + .map_err(|e| anyhow::anyhow!("Invalid secondary gateway: {}", e))?; + let ping_target: Ipv4Addr = config_with_env + .ping_target + .parse() + .map_err(|e| anyhow::anyhow!("Invalid ping target: {}", e))?; + + // Set up signal handling + let term = Arc::new(AtomicBool::new(false)); + signal_flag::register(SIGINT, Arc::clone(&term))?; + signal_flag::register(SIGTERM, Arc::clone(&term))?; + signal_flag::register(SIGQUIT, Arc::clone(&term))?; + + let (shutdown_tx, _) = broadcast::channel::<()>(1); + + // Run the main service + let result = main_service( + config_with_env, + primary_gateway, + secondary_gateway, + ping_target, + term, + shutdown_tx, + ) + .await; + + if let Err(e) = result { + error!("Service error: {}", e); + return Err(e); + } + + info!("Route-Switcher shut down gracefully"); + Ok(()) +} + +use state_machine::StateMachine; + +async fn main_service( + config: Config, + primary_gateway: Ipv4Addr, + secondary_gateway: Ipv4Addr, + ping_target: Ipv4Addr, + term: Arc, + shutdown_tx: broadcast::Sender<()>, +) -> Result<()> { + info!( + "Starting main service with interfaces: {} (primary), {} (secondary)", + config.primary_interface, config.secondary_interface + ); + + // Initialize route manager and set up initial routes + let mut route_manager = routing::RouteManager::new(); + route_manager.setup_initial_routes( + primary_gateway, + config.primary_interface.clone(), + secondary_gateway, + config.secondary_interface.clone(), + )?; + info!("Initial routes configured: primary (metric 10), secondary (metric 20)"); + + // Create pingers + let primary_pinger = pinger::AsyncPinger::new(config.primary_interface.clone()); + let secondary_pinger = pinger::AsyncPinger::new(config.secondary_interface.clone()); + + // Start monitoring + let ping_interval = Duration::from_secs(config.ping_interval); + let primary_shutdown = shutdown_tx.subscribe(); + let secondary_shutdown = shutdown_tx.subscribe(); + + let mut primary_rx = primary_pinger + .start_monitoring(ping_target, ping_interval, primary_shutdown) + .await; + + let mut secondary_rx = secondary_pinger + .start_monitoring(ping_target, ping_interval, secondary_shutdown) + .await; + + // Initialize state machine + let mut state_machine = StateMachine::new( + config.failover_threshold, + Duration::from_secs(config.failback_delay), + ); + + // Main event loop + loop { + tokio::select! { + // Handle primary ping results + Some(result) = primary_rx.recv() => { + debug!("Primary ping result: {}", result); + state_machine.add_primary_result(result); + + if let Some((old_state, new_state)) = state_machine.update_state() { + state_machine::handle_state_change(new_state, old_state, &mut route_manager, &primary_gateway, &secondary_gateway, &config)?; + } + } + + // Handle secondary ping results + Some(result) = secondary_rx.recv() => { + debug!("Secondary ping result: {}", result); + state_machine.add_secondary_result(result); + + if let Some((old_state, new_state)) = state_machine.update_state() { + state_machine::handle_state_change(new_state, old_state, &mut route_manager, &primary_gateway, &secondary_gateway, &config)?; + } + } + + // Handle shutdown signal + _ = signal::ctrl_c() => { + info!("Received Ctrl+C, shutting down..."); + break; + } + + // Check termination flag + _ = tokio::task::spawn_blocking({ + let term = Arc::clone(&term); + move || { + while !term.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(100)); + } + } + }) => { + info!("Received termination signal"); + break; + } + } + } + + // Clean up only the failover route on exit if we're in Fallback state + if state_machine.get_state() == &state_machine::State::Fallback { + route_manager + .remove_failover_route(secondary_gateway, config.secondary_interface.clone())?; + info!("Failover route cleared on exit"); + } + + Ok(()) +} diff --git a/src/pinger.rs b/src/pinger.rs new file mode 100644 index 0000000..f7b5cec --- /dev/null +++ b/src/pinger.rs @@ -0,0 +1,205 @@ +use anyhow::Result; +use log; +use pnet::packet::Packet; +use pnet::packet::icmp::echo_reply::EchoReplyPacket; +use pnet::packet::icmp::{IcmpTypes, IcmpTypes::EchoReply, echo_request}; +use pnet::transport::{ + TransportChannelType::Layer4, TransportProtocol::Ipv4, icmp_packet_iter, transport_channel, +}; +use pnet_sys; +use std::net::Ipv4Addr; +use std::time::{Duration, Instant}; + +const ICMP_HEADER_SIZE: usize = 8; +const PAYLOAD: &str = "hello world!"; + +#[derive(Debug, PartialEq, Clone)] +pub enum PingResult { + Ok, + Failed, +} +impl std::fmt::Display for PingResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PingResult::Ok => write!(f, "Ok"), + PingResult::Failed => write!(f, "Failed"), + } + } +} + +pub struct AsyncPinger { + interface: String, +} + +impl AsyncPinger { + pub fn new(interface: String) -> Self { + Self { interface } + } + + pub async fn ping(&self, destination: Ipv4Addr) -> PingResult { + let (mut tx, mut rx) = match create_transport_channel() { + Ok((tx, rx)) => (tx, rx), + Err(_) => return PingResult::Failed, + }; + + let fd = tx.socket.fd; + let it = self.interface.as_bytes(); + + let res = unsafe { + pnet_sys::setsockopt( + fd, + pnet_sys::SOL_SOCKET, + libc::SO_BINDTODEVICE, + (it.as_ptr() as *const libc::c_char) as pnet_sys::Buf, + it.len() as pnet_sys::SockLen, + ) + }; + + if res == -1 { + log::error!("Failed to bind to {}", self.interface); + return PingResult::Failed; + } + + let identifier = rand::random::(); + let sequence_number = rand::random::(); + + if let Err(_) = send_echo_request(&mut tx, destination, identifier, sequence_number) { + log::error!("Failed to send echo request"); + return PingResult::Failed; + } + let r = receive_echo_reply( + &mut rx, + destination, + identifier, + sequence_number, + &self.interface, + ) + .await; + r + } + + pub async fn start_monitoring( + &self, + destination: Ipv4Addr, + interval: Duration, + mut shutdown: tokio::sync::broadcast::Receiver<()>, + ) -> tokio::sync::mpsc::UnboundedReceiver { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let interface = self.interface.clone(); + + tokio::spawn(async move { + let mut interval_timer = tokio::time::interval(interval); + + loop { + tokio::select! { + _ = interval_timer.tick() => { + let pinger = AsyncPinger::new(interface.clone()); + let result = pinger.ping(destination).await; + if tx.send(result).is_err() { + break; + } + } + _ = shutdown.recv() => { + log::info!("Shutting down pinger for interface {}", interface); + break; + } + } + } + }); + + rx + } +} + +fn create_transport_channel() -> Result<( + pnet::transport::TransportSender, + pnet::transport::TransportReceiver, +)> { + transport_channel( + 1024, + Layer4(Ipv4(pnet::packet::ip::IpNextHeaderProtocols::Icmp)), + ) + .map_err(|e| anyhow::anyhow!("Failed to create transport channel: {}", e)) +} + +fn send_echo_request( + tx: &mut pnet::transport::TransportSender, + destination: Ipv4Addr, + identifier: u16, + sequence_number: u16, +) -> Result<()> { + let mut buffer = [0u8; ICMP_HEADER_SIZE + 4 + PAYLOAD.len()]; + let mut echo_request_packet = echo_request::MutableEchoRequestPacket::new(&mut buffer).unwrap(); + echo_request_packet.set_icmp_type(IcmpTypes::EchoRequest); + echo_request_packet.set_identifier(identifier); + echo_request_packet.set_sequence_number(sequence_number); + echo_request_packet.set_payload(PAYLOAD.as_bytes()); + echo_request_packet.set_checksum(pnet::util::checksum(echo_request_packet.packet(), 1)); + + tx.send_to(echo_request_packet, std::net::IpAddr::V4(destination)) + .map_err(|e| anyhow::anyhow!("Failed to send packet: {}", e))?; + + Ok(()) +} + +async fn receive_echo_reply( + rx: &mut pnet::transport::TransportReceiver, + destination: Ipv4Addr, + identifier: u16, + sequence_number: u16, + interface: &str, +) -> PingResult { + let timeout = Duration::from_secs(3); + let start = Instant::now(); + let mut iter = icmp_packet_iter(rx); + + loop { + match iter.next_with_timeout(Duration::from_secs(1)) { + Ok(None) => { + log::debug!( + "ICMP receive timeout for {} on interface {} (elapsed: {} ms)", + destination, + interface, + start.elapsed().as_millis() + ); + } + Ok(Some((packet, addr))) + if addr == destination && packet.get_icmp_type() == EchoReply => + { + if let Some(reply) = EchoReplyPacket::new(packet.packet()) { + if reply.get_identifier() == identifier + && reply.get_sequence_number() == sequence_number + { + log::debug!( + "Received ICMP Echo Reply from {} on interface {} in {} ms", + addr, + interface, + start.elapsed().as_millis() + ); + return PingResult::Ok; + } + } else { + log::error!("Got something, but it looks like it is not mine!"); + } + } + Ok(Some((_packet, _addr))) => { + continue; + } + Err(e) => { + log::error!("Error receiving packet: {}", e); + break; + } + } + + if start.elapsed() >= timeout { + log::debug!( + "Request timed out for {} on interface {}", + destination, + interface + ); + break; + } + } + + PingResult::Failed +} diff --git a/src/routing.rs b/src/routing.rs new file mode 100644 index 0000000..8151fdf --- /dev/null +++ b/src/routing.rs @@ -0,0 +1,340 @@ +use anyhow::Result; +use libc::if_nametoindex; +use log::{debug, info}; +use netlink_packet_route::route::RouteAddress; +use std::ffi::CString; +use std::net::Ipv4Addr; + +const MAIN_TABLE_ID: u8 = 254; + +#[derive(Debug, Clone)] +pub struct RouteInfo { + pub gateway: Ipv4Addr, + pub interface: String, + pub metric: u32, +} + +pub struct RouteManager { + routes: Vec, +} + +impl RouteManager { + pub fn new() -> Self { + RouteManager { routes: Vec::new() } + } + + pub fn add_route(&mut self, gateway: Ipv4Addr, interface: String, metric: u32) -> Result<()> { + let route_info = RouteInfo { + gateway, + interface: interface.clone(), + metric, + }; + + self.add_default_route_internal(&route_info)?; + self.routes.push(route_info); + + info!( + "Added route: {} via {} dev {} metric {}", + Ipv4Addr::new(0, 0, 0, 0), + gateway, + interface, + metric + ); + Ok(()) + } + + pub fn remove_route(&mut self, gateway: Ipv4Addr, interface: &str, metric: u32) -> Result<()> { + self.delete_default_route_internal(gateway, interface, metric)?; + + self.routes + .retain(|r| !(r.gateway == gateway && r.interface == interface && r.metric == metric)); + + info!( + "Removed route: {} via {} dev {} metric {}", + Ipv4Addr::new(0, 0, 0, 0), + gateway, + interface, + metric + ); + Ok(()) + } + + pub fn set_primary_route(&mut self, gateway: Ipv4Addr, interface: String) -> Result<()> { + let primary_metric = 10; + + // Remove existing routes for this interface if any + if let Some(pos) = self.routes.iter().position(|r| r.interface == interface) { + let existing_route = self.routes[pos].clone(); + self.remove_route( + existing_route.gateway, + &existing_route.interface, + existing_route.metric, + )?; + } + + // Add as primary route + self.add_route(gateway, interface, primary_metric)?; + Ok(()) + } + + pub fn setup_initial_routes( + &mut self, + primary_gateway: Ipv4Addr, + primary_interface: String, + secondary_gateway: Ipv4Addr, + secondary_interface: String, + ) -> Result<()> { + // Set primary route with metric 10 (default) + self.set_primary_route(primary_gateway, primary_interface)?; + + // Set secondary route with metric 20 (lower priority) + let secondary_metric = 20; + self.add_route(secondary_gateway, secondary_interface, secondary_metric)?; + Ok(()) + } + + pub fn add_failover_route(&mut self, gateway: Ipv4Addr, interface: String) -> Result<()> { + let failover_metric = 5; // Higher priority than both primary (10) and secondary (20) + self.add_route(gateway, interface, failover_metric)?; + Ok(()) + } + + pub fn remove_failover_route(&mut self, gateway: Ipv4Addr, interface: String) -> Result<()> { + let failover_metric = 5; + self.remove_route(gateway, &interface, failover_metric)?; + Ok(()) + } + + #[allow(dead_code)] + pub fn get_routes(&self) -> &[RouteInfo] { + &self.routes + } + + fn add_default_route_internal(&self, route_info: &RouteInfo) -> Result<()> { + let index = get_interface_index(&route_info.interface)?; + + if index == 0 { + return Err(anyhow::anyhow!( + "Interface '{}' not found", + route_info.interface + )); + } + + use netlink_packet_core::{ + NLM_F_ACK, NLM_F_CREATE, NLM_F_REQUEST, NetlinkHeader, NetlinkMessage, NetlinkPayload, + }; + use netlink_packet_route::{ + AddressFamily, RouteNetlinkMessage, + route::RouteProtocol, + route::RouteScope, + route::{RouteAttribute, RouteHeader, RouteMessage, RouteType}, + }; + use netlink_sys::{Socket, SocketAddr, protocols::NETLINK_ROUTE}; + + let mut socket = Socket::new(NETLINK_ROUTE)?; + let _port_number = socket.bind_auto()?.port_number(); + socket.connect(&SocketAddr::new(0, 0))?; + let route_msg_hdr = RouteHeader { + address_family: AddressFamily::Inet, + table: MAIN_TABLE_ID, + destination_prefix_length: 0, // Default route + protocol: RouteProtocol::Boot, + scope: RouteScope::Universe, + kind: RouteType::Unicast, + ..Default::default() + }; + + let mut route_msg = RouteMessage::default(); + route_msg.header = route_msg_hdr; + route_msg.attributes = vec![ + RouteAttribute::Gateway(RouteAddress::Inet(route_info.gateway)), + RouteAttribute::Oif(index), + RouteAttribute::Priority(route_info.metric), + ]; + let mut nl_hdr = NetlinkHeader::default(); + nl_hdr.flags = NLM_F_REQUEST | NLM_F_CREATE | NLM_F_ACK; // Remove NLM_F_EXCL to allow updates + + let mut msg = NetlinkMessage::new( + nl_hdr, + NetlinkPayload::from(RouteNetlinkMessage::NewRoute(route_msg)), + ); + + msg.finalize(); + let mut buf = vec![0; 1024 * 8]; + + msg.serialize(&mut buf[..msg.buffer_len()]); + + // Debug: Log the netlink message being sent + debug!("Netlink message being sent: {:?}", &buf[..msg.buffer_len()]); + debug!( + "Route addition attempt: gateway={}, interface={}, metric={}, interface_index={}", + route_info.gateway, route_info.interface, route_info.metric, index + ); + + socket.send(&buf, 0)?; + + let mut receive_buffer = Vec::with_capacity(256); + let size = socket.recv(&mut receive_buffer, 0)?; + + if size == 0 { + return Err(anyhow::anyhow!("No response from netlink")); + } + + let bytes = &receive_buffer[..size]; + let rx_packet = >::deserialize(bytes); + + match rx_packet { + Ok(rx_packet) => { + if let NetlinkPayload::Error(error_msg) = rx_packet.payload { + if let Some(code) = error_msg.code { + let error_code = code.get(); + if error_code == -17 { + // EEXIST - Route already exists, treat as success + info!( + "Route already exists: {} via {} dev {} metric {}", + Ipv4Addr::new(0, 0, 0, 0), + route_info.gateway, + route_info.interface, + route_info.metric + ); + } else { + let error_str = match error_code { + -1 => "EPERM - Operation not permitted (need root privileges)", + -2 => "ENOENT - No such file or directory", + -13 => "EACCES - Permission denied", + -22 => "EINVAL - Invalid argument", + _ => "Unknown error", + }; + return Err(anyhow::anyhow!( + "Failed to add route: {} (code: {}): {:?}", + error_str, + error_code, + error_msg + )); + } + } + debug!("Route added successfully"); + } + } + Err(e) => { + return Err(anyhow::anyhow!( + "Failed to deserialize netlink message: {}", + e + )); + } + } + + Ok(()) + } + + fn delete_default_route_internal( + &self, + gateway: Ipv4Addr, + interface: &str, + metric: u32, + ) -> Result<()> { + let index = get_interface_index(interface)?; + + if index == 0 { + return Err(anyhow::anyhow!("Interface '{}' not found", interface)); + } + + use netlink_packet_core::{ + NLM_F_ACK, NLM_F_REQUEST, NetlinkHeader, NetlinkMessage, NetlinkPayload, + }; + use netlink_packet_route::{ + AddressFamily, RouteNetlinkMessage, + route::RouteProtocol, + route::RouteScope, + route::{RouteAttribute, RouteHeader, RouteMessage, RouteType}, + }; + use netlink_sys::{Socket, SocketAddr, protocols::NETLINK_ROUTE}; + + let mut socket = Socket::new(NETLINK_ROUTE)?; + let _port_number = socket.bind_auto()?.port_number(); + socket.connect(&SocketAddr::new(0, 0))?; + + let route_msg_hdr = RouteHeader { + address_family: AddressFamily::Inet, + table: MAIN_TABLE_ID, + destination_prefix_length: 0, // Default route + protocol: RouteProtocol::Boot, + scope: RouteScope::Universe, + kind: RouteType::Unicast, + ..Default::default() + }; + + let mut route_msg = RouteMessage::default(); + route_msg.header = route_msg_hdr; + route_msg.attributes = vec![ + RouteAttribute::Gateway(RouteAddress::Inet(gateway)), + RouteAttribute::Oif(index), + RouteAttribute::Priority(metric), + ]; + + let mut nl_hdr = NetlinkHeader::default(); + nl_hdr.flags = NLM_F_REQUEST | NLM_F_ACK; + + let mut msg = NetlinkMessage::new( + nl_hdr, + NetlinkPayload::from(RouteNetlinkMessage::DelRoute(route_msg)), + ); + + msg.finalize(); + let mut buf = vec![0; 1024 * 8]; + + msg.serialize(&mut buf[..msg.buffer_len()]); + + // Debug: Log the netlink message being sent + debug!( + "Netlink delete message being sent: {:?}", + &buf[..msg.buffer_len()] + ); + debug!( + "Route deletion attempt: gateway={}, interface={}, metric={}, interface_index={}", + gateway, interface, metric, index + ); + + socket.send(&buf, 0)?; + + let mut receive_buffer = Vec::with_capacity(256); + let size = socket.recv(&mut receive_buffer, 0)?; + + if size == 0 { + return Err(anyhow::anyhow!("No response from netlink")); + } + + let bytes = &receive_buffer[..size]; + let rx_packet = >::deserialize(bytes); + + match rx_packet { + Ok(rx_packet) => { + if let NetlinkPayload::Error(error_msg) = rx_packet.payload { + if error_msg.code.is_some() { + return Err(anyhow::anyhow!("Failed to delete route: {:?}", error_msg)); + } + debug!("Route deleted successfully"); + } + } + Err(e) => { + return Err(anyhow::anyhow!( + "Failed to deserialize netlink message: {}", + e + )); + } + } + + Ok(()) + } +} + +fn get_interface_index(iface_name: &str) -> Result { + let c_name = CString::new(iface_name)?; + let index = unsafe { if_nametoindex(c_name.as_ptr()) }; + + if index == 0 { + Err(anyhow::anyhow!("Interface '{}' not found", iface_name)) + } else { + Ok(index) + } +} diff --git a/src/state_machine.rs b/src/state_machine.rs new file mode 100644 index 0000000..c7f8618 --- /dev/null +++ b/src/state_machine.rs @@ -0,0 +1,487 @@ +use log::{info, warn}; +use std::collections::VecDeque; +use std::time::Duration; + +use crate::pinger; +use crate::routing; + +#[derive(Debug, PartialEq, Clone)] +pub enum State { + Boot, + Primary, + Fallback, +} + +pub struct StateMachine { + state: State, + primary_history: VecDeque, + secondary_history: VecDeque, + failover_threshold: usize, + failback_delay: Duration, + last_failover: Option, + boot_start_time: std::time::Instant, +} + +impl StateMachine { + pub fn new(failover_threshold: usize, failback_delay: Duration) -> Self { + Self { + state: State::Boot, + primary_history: VecDeque::with_capacity(60), + secondary_history: VecDeque::with_capacity(60), + failover_threshold, + failback_delay, + last_failover: None, + boot_start_time: std::time::Instant::now(), + } + } + + pub fn add_primary_result(&mut self, result: pinger::PingResult) { + self.primary_history.push_front(result); + if self.primary_history.len() > 60 { + self.primary_history.pop_back(); + } + } + + pub fn add_secondary_result(&mut self, result: pinger::PingResult) { + self.secondary_history.push_front(result); + if self.secondary_history.len() > 60 { + self.secondary_history.pop_back(); + } + } + + fn should_fallback(&self) -> bool { + if self.primary_history.len() < self.failover_threshold { + return false; + } + + // Check if we have enough consecutive failures + self.primary_history + .iter() + .take(self.failover_threshold) + .all(|x| *x == pinger::PingResult::Failed) + } + + fn should_failback(&self) -> bool { + if self.secondary_history.len() < 3 { + return false; + } + + // Check if secondary is working + let secondary_working = self + .secondary_history + .iter() + .take(3) + .any(|x| *x == pinger::PingResult::Ok); + + if !secondary_working { + return false; + } + + // Check if primary is working consistently + if self.primary_history.len() < 10 { + return false; + } + + let primary_working = self + .primary_history + .iter() + .take(10) + .all(|x| *x == pinger::PingResult::Ok); + + if !primary_working { + return false; + } + + // Check if enough time has passed since last failover + if let Some(last_failover) = self.last_failover { + last_failover.elapsed() >= self.failback_delay + } else { + true + } + } + + pub fn get_state(&self) -> &State { + &self.state + } + + pub fn update_state(&mut self) -> Option<(State, State)> { + let old_state = self.state.clone(); + + match self.state { + State::Boot => { + // Check if 10 seconds have passed since boot start + if self.boot_start_time.elapsed() >= Duration::from_secs(10) { + self.state = State::Primary; + info!("Boot phase complete (10s sampling), switching to Primary state"); + } + } + State::Primary => { + if self.should_fallback() { + // Verify secondary is working before failing over + if self.secondary_history.len() >= 3 { + let secondary_working = self + .secondary_history + .iter() + .take(3) + .all(|x| *x == pinger::PingResult::Ok); + + if secondary_working { + self.state = State::Fallback; + self.last_failover = Some(std::time::Instant::now()); + warn!("Primary interface failing over to secondary"); + } else { + warn!( + "Primary interface failing but secondary also down - not switching" + ); + } + } + } + } + State::Fallback => { + if self.should_failback() { + self.state = State::Primary; + info!("Primary interface restored, failing back"); + } + } + } + + if old_state != self.state { + Some((old_state, self.state.clone())) + } else { + None + } + } +} + +pub fn handle_state_change( + new_state: State, + old_state: State, + route_manager: &mut routing::RouteManager, + _primary_gateway: &std::net::Ipv4Addr, + secondary_gateway: &std::net::Ipv4Addr, + config: &crate::Config, +) -> anyhow::Result<()> { + match (old_state, new_state) { + (State::Fallback, State::Primary) => { + // Only remove failover route when transitioning from Fallback to Primary + info!("Switching to primary route (failback)"); + route_manager + .remove_failover_route(*secondary_gateway, config.secondary_interface.clone())?; + } + (_, State::Fallback) => { + info!("Switching to secondary route (failover)"); + // Add extra failover route with higher priority (metric 5) + route_manager + .add_failover_route(*secondary_gateway, config.secondary_interface.clone())?; + } + (State::Boot, State::Primary) => { + // Boot -> Primary transition should not remove any routes + info!("Boot phase complete, entering Primary state"); + } + _ => { + // Other transitions don't require route changes + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pinger::PingResult; + + #[test] + fn test_state_machine_initialization() { + let sm = StateMachine::new(3, Duration::from_secs(60)); + assert_eq!(sm.state, State::Boot); + assert_eq!(sm.failover_threshold, 3); + assert_eq!(sm.failback_delay, Duration::from_secs(60)); + assert!(sm.primary_history.is_empty()); + assert!(sm.secondary_history.is_empty()); + assert!(sm.last_failover.is_none()); + } + + #[test] + fn test_add_primary_result() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + + sm.add_primary_result(PingResult::Ok); + assert_eq!(sm.primary_history.len(), 1); + assert_eq!(sm.primary_history[0], PingResult::Ok); + + sm.add_primary_result(PingResult::Failed); + assert_eq!(sm.primary_history.len(), 2); + assert_eq!(sm.primary_history[0], PingResult::Failed); + assert_eq!(sm.primary_history[1], PingResult::Ok); + } + + #[test] + fn test_add_secondary_result() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + + sm.add_secondary_result(PingResult::Ok); + assert_eq!(sm.secondary_history.len(), 1); + assert_eq!(sm.secondary_history[0], PingResult::Ok); + + sm.add_secondary_result(PingResult::Failed); + assert_eq!(sm.secondary_history.len(), 2); + assert_eq!(sm.secondary_history[0], PingResult::Failed); + assert_eq!(sm.secondary_history[1], PingResult::Ok); + } + + #[test] + fn test_history_capacity_limit() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + + // Add 65 results (more than capacity of 60) + for _ in 0..65 { + sm.add_primary_result(PingResult::Ok); + } + + assert_eq!(sm.primary_history.len(), 60); + } + + #[test] + fn test_should_fallback_insufficient_history() { + let sm = StateMachine::new(3, Duration::from_secs(60)); + assert!(!sm.should_fallback()); // Empty history + + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + sm.add_primary_result(PingResult::Failed); + sm.add_primary_result(PingResult::Failed); + assert!(!sm.should_fallback()); // Only 2 results, need 3 + } + + #[test] + fn test_should_fallback_consecutive_failures() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + + // Add 3 consecutive failures + sm.add_primary_result(PingResult::Failed); + sm.add_primary_result(PingResult::Failed); + sm.add_primary_result(PingResult::Failed); + + assert!(sm.should_fallback()); + } + + #[test] + fn test_should_fallback_mixed_results() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + + // Add mixed results (not all failures) + sm.add_primary_result(PingResult::Failed); + sm.add_primary_result(PingResult::Ok); + sm.add_primary_result(PingResult::Failed); + + assert!(!sm.should_fallback()); + } + + #[test] + fn test_should_failback_insufficient_secondary_history() { + let sm = StateMachine::new(3, Duration::from_secs(60)); + assert!(!sm.should_failback()); // Empty secondary history + + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + sm.add_secondary_result(PingResult::Ok); + sm.add_secondary_result(PingResult::Ok); + assert!(!sm.should_failback()); // Only 2 results, need 3 + } + + #[test] + fn test_should_failback_secondary_not_working() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + + // Add 3 secondary failures + for _ in 0..3 { + sm.add_secondary_result(PingResult::Failed); + } + + assert!(!sm.should_failback()); + } + + #[test] + fn test_should_failback_insufficient_primary_history() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + + // Add 3 working secondary results + for _ in 0..3 { + sm.add_secondary_result(PingResult::Ok); + } + + // Add only 5 primary results (need 10) + for _ in 0..5 { + sm.add_primary_result(PingResult::Ok); + } + + assert!(!sm.should_failback()); + } + + #[test] + fn test_should_failback_primary_not_working() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + + // Add 3 working secondary results + for _ in 0..3 { + sm.add_secondary_result(PingResult::Ok); + } + + // Add 10 mixed primary results + for _ in 0..5 { + sm.add_primary_result(PingResult::Ok); + sm.add_primary_result(PingResult::Failed); + } + + assert!(!sm.should_failback()); + } + + #[test] + fn test_should_failback_success() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + + // Add 3 working secondary results + for _ in 0..3 { + sm.add_secondary_result(PingResult::Ok); + } + + // Add 10 working primary results + for _ in 0..10 { + sm.add_primary_result(PingResult::Ok); + } + + assert!(sm.should_failback()); + } + + #[test] + fn test_should_failback_respect_delay() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + + // Set up conditions for failback + for _ in 0..3 { + sm.add_secondary_result(PingResult::Ok); + } + for _ in 0..10 { + sm.add_primary_result(PingResult::Ok); + } + + // Simulate recent failover + sm.last_failover = Some(std::time::Instant::now()); + + assert!(!sm.should_failback()); // Should not failback immediately + } + + #[test] + fn test_update_state_boot_to_primary() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + + // Simulate boot phase completion by manipulating boot_start_time + sm.boot_start_time = std::time::Instant::now() - Duration::from_secs(11); + + let transition = sm.update_state(); + assert!(transition.is_some()); + assert_eq!(transition.unwrap(), (State::Boot, State::Primary)); + assert_eq!(sm.state, State::Primary); + } + + #[test] + fn test_update_state_still_in_boot() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + + // Still in boot phase (less than 10 seconds) + let transition = sm.update_state(); + assert!(transition.is_none()); + assert_eq!(sm.state, State::Boot); + } + + #[test] + fn test_update_state_primary_to_fallback() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + sm.state = State::Primary; + + // Add consecutive primary failures + for _ in 0..3 { + sm.add_primary_result(PingResult::Failed); + } + + // Add working secondary results + for _ in 0..3 { + sm.add_secondary_result(PingResult::Ok); + } + + let transition = sm.update_state(); + assert!(transition.is_some()); + assert_eq!(transition.unwrap(), (State::Primary, State::Fallback)); + assert_eq!(sm.state, State::Fallback); + assert!(sm.last_failover.is_some()); + } + + #[test] + fn test_update_state_primary_no_fallback_secondary_down() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + sm.state = State::Primary; + + // Add consecutive primary failures + for _ in 0..3 { + sm.add_primary_result(PingResult::Failed); + } + + // Add failing secondary results + for _ in 0..3 { + sm.add_secondary_result(PingResult::Failed); + } + + let transition = sm.update_state(); + assert!(transition.is_none()); + assert_eq!(sm.state, State::Primary); + } + + #[test] + fn test_update_state_fallback_to_primary() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + sm.state = State::Fallback; + + // Set up conditions for failback + for _ in 0..3 { + sm.add_secondary_result(PingResult::Ok); + } + for _ in 0..10 { + sm.add_primary_result(PingResult::Ok); + } + + let transition = sm.update_state(); + assert!(transition.is_some()); + assert_eq!(transition.unwrap(), (State::Fallback, State::Primary)); + assert_eq!(sm.state, State::Primary); + } + + #[test] + fn test_update_state_no_transition() { + let mut sm = StateMachine::new(3, Duration::from_secs(60)); + sm.state = State::Primary; + + // Add some mixed results that don't trigger transitions + sm.add_primary_result(PingResult::Ok); + sm.add_primary_result(PingResult::Failed); + + let transition = sm.update_state(); + assert!(transition.is_none()); + assert_eq!(sm.state, State::Primary); + } + + #[test] + fn test_state_equality() { + assert_eq!(State::Boot, State::Boot); + assert_eq!(State::Primary, State::Primary); + assert_eq!(State::Fallback, State::Fallback); + + assert_ne!(State::Boot, State::Primary); + assert_ne!(State::Primary, State::Fallback); + assert_ne!(State::Fallback, State::Boot); + } + + #[test] + fn test_state_clone() { + let state = State::Primary; + let cloned = state.clone(); + assert_eq!(state, cloned); + } +}