working base

This commit is contained in:
Michal Humpula
2025-03-16 10:20:48 +01:00
parent 0ddf5f1c36
commit 5fbd72b370
19 changed files with 3261 additions and 0 deletions

2
.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
target/
Dockerfile
.dockerignore
scripts/
.git/
.gitignore
README.md
ARCHITECTURE.md
TESTING.md

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

12
AGENTS.md Normal file
View File

@@ -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.

910
Cargo.lock generated Normal file
View File

@@ -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",
]

21
Cargo.toml Normal file
View File

@@ -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"] }

31
Dockerfile Normal file
View File

@@ -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"]

181
README.md
View File

@@ -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 <interface>`)
- 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 <primary-gw> dev <primary-iface> metric 10`
- Secondary route: `ip r add default via <secondary-gw> dev <secondary-iface> 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

167
doc/ARCHITECTURE.md Normal file
View File

@@ -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<PingResult>;
}
```
### 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<Vec<RouteInfo>>;
}
```
## 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

270
doc/TESTING.md Normal file
View File

@@ -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

149
docker-compose.yml Normal file
View File

@@ -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:

44
route-switcher.service Normal file
View File

@@ -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

53
scripts/setup-router.sh Executable file
View File

@@ -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

118
scripts/test-failover.sh Executable file
View File

@@ -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"

35
scripts/verify-setup.sh Executable file
View File

@@ -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."

226
src/main.rs Normal file
View File

@@ -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<AtomicBool>,
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(())
}

205
src/pinger.rs Normal file
View File

@@ -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::<u16>();
let sequence_number = rand::random::<u16>();
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<PingResult> {
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
}

340
src/routing.rs Normal file
View File

@@ -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<RouteInfo>,
}
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 = <NetlinkMessage<RouteNetlinkMessage>>::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 = <NetlinkMessage<RouteNetlinkMessage>>::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<u32> {
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)
}
}

487
src/state_machine.rs Normal file
View File

@@ -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<pinger::PingResult>,
secondary_history: VecDeque<pinger::PingResult>,
failover_threshold: usize,
failback_delay: Duration,
last_failover: Option<std::time::Instant>,
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);
}
}