Compare commits
28
Commits
v0.1.7
...
6f59c0c011
No files matched your search
Generated
+367
-13
@@ -26,6 +26,56 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.100"
|
version = "1.0.100"
|
||||||
@@ -206,6 +256,46 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.5.60"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.5.60"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
|
||||||
|
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.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "color-eyre"
|
name = "color-eyre"
|
||||||
version = "0.6.5"
|
version = "0.6.5"
|
||||||
@@ -233,12 +323,52 @@ dependencies = [
|
|||||||
"tracing-error",
|
"tracing-error",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "comfy-table"
|
||||||
|
version = "7.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
|
||||||
|
dependencies = [
|
||||||
|
"crossterm",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"document-features",
|
||||||
|
"parking_lot",
|
||||||
|
"rustix",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm_winapi"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.21.3"
|
version = "0.21.3"
|
||||||
@@ -284,6 +414,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "document-features"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||||
|
dependencies = [
|
||||||
|
"litrs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dyn-clone"
|
name = "dyn-clone"
|
||||||
version = "1.0.20"
|
version = "1.0.20"
|
||||||
@@ -603,6 +742,12 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -627,7 +772,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lda-ipjs"
|
name = "lda-ipjs"
|
||||||
version = "0.0.1"
|
version = "0.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -647,6 +792,28 @@ version = "0.2.180"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lock_api"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"scopeguard",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
@@ -800,12 +967,41 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "owo-colors"
|
name = "owo-colors"
|
||||||
version = "4.2.3"
|
version = "4.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
|
checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot_core"
|
||||||
|
version = "0.9.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"redox_syscall",
|
||||||
|
"smallvec",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@@ -854,6 +1050,15 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ref-cast"
|
name = "ref-cast"
|
||||||
version = "1.0.25"
|
version = "1.0.25"
|
||||||
@@ -898,6 +1103,19 @@ version = "0.1.27"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
|
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -934,6 +1152,12 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@@ -1363,12 +1587,30 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlencoding"
|
name = "urlencoding"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -1382,19 +1624,45 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
|
"comfy-table",
|
||||||
"futures",
|
"futures",
|
||||||
"lda-ipjs",
|
"lda-ipjs",
|
||||||
"macaddr",
|
"macaddr",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_html_form",
|
"serde_html_form",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
|
||||||
"strum",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
|
"wakey-core",
|
||||||
|
"wakey-linux",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wakey-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"macaddr",
|
||||||
|
"serde",
|
||||||
|
"serde_with",
|
||||||
|
"strum",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wakey-linux"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"futures",
|
||||||
|
"lda-ipjs",
|
||||||
|
"macaddr",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"wakey-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1448,6 +1716,28 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -1513,7 +1803,7 @@ version = "0.60.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets",
|
"windows-targets 0.53.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1525,6 +1815,22 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
|
"windows_aarch64_msvc 0.52.6",
|
||||||
|
"windows_i686_gnu 0.52.6",
|
||||||
|
"windows_i686_gnullvm 0.52.6",
|
||||||
|
"windows_i686_msvc 0.52.6",
|
||||||
|
"windows_x86_64_gnu 0.52.6",
|
||||||
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
|
"windows_x86_64_msvc 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.53.5"
|
version = "0.53.5"
|
||||||
@@ -1532,58 +1838,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
"windows_aarch64_gnullvm",
|
"windows_aarch64_gnullvm 0.53.1",
|
||||||
"windows_aarch64_msvc",
|
"windows_aarch64_msvc 0.53.1",
|
||||||
"windows_i686_gnu",
|
"windows_i686_gnu 0.53.1",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm 0.53.1",
|
||||||
"windows_i686_msvc",
|
"windows_i686_msvc 0.53.1",
|
||||||
"windows_x86_64_gnu",
|
"windows_x86_64_gnu 0.53.1",
|
||||||
"windows_x86_64_gnullvm",
|
"windows_x86_64_gnullvm 0.53.1",
|
||||||
"windows_x86_64_msvc",
|
"windows_x86_64_msvc 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
|
|||||||
+6
-4
@@ -8,15 +8,15 @@ publish = ["gitea"]
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
axum = { version = "0", features = ["macros"] }
|
axum = { version = "0", features = ["macros"] }
|
||||||
axum-extra = { version = "0", features = ["query"] }
|
axum-extra = { version = "0", features = ["query"] }
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
color-eyre = "0"
|
color-eyre = "0"
|
||||||
|
comfy-table = "7"
|
||||||
futures = "0"
|
futures = "0"
|
||||||
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_html_form = "0"
|
serde_html_form = "0"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_with = { version = "3", features = ["json"] }
|
|
||||||
strum = { version = "0", features = ["derive", "strum_macros"] }
|
|
||||||
thiserror = "2"
|
|
||||||
tokio = { version = "1", features = [
|
tokio = { version = "1", features = [
|
||||||
"fs",
|
"fs",
|
||||||
"process",
|
"process",
|
||||||
@@ -26,6 +26,8 @@ tokio = { version = "1", features = [
|
|||||||
] }
|
] }
|
||||||
tower-http = { version = "0", features = ["fs"] }
|
tower-http = { version = "0", features = ["fs"] }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
wakey-core = { path = "wakey-core" }
|
||||||
|
wakey-linux = { path = "wakey-linux" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
@@ -37,7 +39,7 @@ very-smart-parsing = [
|
|||||||
] # this is the a-bit-redundant parse thing that copilot made
|
] # this is the a-bit-redundant parse thing that copilot made
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["ipjs"]
|
members = ["ipjs", "wakey-core", "wakey-linux"]
|
||||||
|
|
||||||
[dependencies.lda-ipjs]
|
[dependencies.lda-ipjs]
|
||||||
path = "ipjs"
|
path = "ipjs"
|
||||||
|
|||||||
@@ -2,19 +2,16 @@
|
|||||||
|
|
||||||
router shit
|
router shit
|
||||||
|
|
||||||
## usage
|
## whatever i did must be BEAUTIFUL
|
||||||
|
|
||||||
- Build (Windows host OK):
|
read [in vietnamese](https://ldlda.com/blogs/else/ssh-ax3000cv2-update-firmware-0-10-2)
|
||||||
- cargo build --release --target armv7-unknown-linux-musleabihf
|
|
||||||
- Run on a Linux box/router:
|
|
||||||
- copy target/armv7-unknown-linux-musleabihf/release/wakey to the device
|
|
||||||
- chmod +x wakey && ./wakey
|
|
||||||
- Install via release asset (OpenWrt):
|
|
||||||
- wget -O- `https://your.gitea/owner/repo/releases/download/vX.Y.Z/wakey-rootfs-vX.Y.Z-armv7-unknown-linux-musleabihf.tgz` | tar -xz -C /
|
|
||||||
- /etc/init.d/wakey enable && /etc/init.d/wakey start
|
|
||||||
|
|
||||||
Optional updater (fetch latest automatically on OpenWrt):
|
## now i NEED the split arch to be on
|
||||||
|
|
||||||
```sh
|
like RN rn rn so i can have web outside and the lil agent can be sitting here configuring itself (Parse/append to rc.local files? add to init.d? fuhh)
|
||||||
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
|
|
||||||
```
|
I NEED IT
|
||||||
|
|
||||||
|
I NEEED IT
|
||||||
|
|
||||||
|
I NEEEEEED it.
|
||||||
+15
-3
@@ -1,10 +1,14 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lda-ipjs"
|
name = "lda-ipjs"
|
||||||
description = "ip -j show schemas"
|
description = "ip -j show schemas"
|
||||||
version = "0.0.1"
|
version = "0.0.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
experimental-nl = ["dep:rtnetlink"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
||||||
strum = { version = "0", features = ["derive", "strum_macros"] }
|
strum = { version = "0", features = ["derive", "strum_macros"] }
|
||||||
@@ -13,7 +17,15 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_with = { version = "3", features = ["json"] }
|
serde_with = { version = "3", features = ["json"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
tokio = { version = "1", features = ["fs", "process", "rt-multi-thread", "io-util", "macros"] }
|
tokio = { version = "1", features = [
|
||||||
rtnetlink = "0"
|
"fs",
|
||||||
|
"process",
|
||||||
|
"rt-multi-thread",
|
||||||
|
"io-util",
|
||||||
|
"macros",
|
||||||
|
] }
|
||||||
futures = "0"
|
futures = "0"
|
||||||
|
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
rtnetlink = { version = "0", optional = true }
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# lda-ipjs
|
||||||
|
|
||||||
|
Typed Rust wrappers around Linux `ip -j ...` output, with optional experimental rtnetlink backends.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`lda-ipjs` exists so `wakey` can ask Linux networking questions in typed Rust instead of:
|
||||||
|
|
||||||
|
- parsing plain-text shell output
|
||||||
|
- scattering `tokio::process::Command::new("ip")` everywhere
|
||||||
|
- mixing product logic with Linux networking trivia
|
||||||
|
|
||||||
|
## Current contract
|
||||||
|
|
||||||
|
Stable default behavior:
|
||||||
|
|
||||||
|
- `address::get(...)` uses JSON (`ip -j address show`)
|
||||||
|
- `link::get(...)` uses JSON (`ip -j link show`)
|
||||||
|
- `neighbor::get(...)` uses JSON (`ip -j neigh show`)
|
||||||
|
|
||||||
|
Optional experimental behavior:
|
||||||
|
|
||||||
|
- feature: `experimental-nl`
|
||||||
|
- enables rtnetlink-backed implementations
|
||||||
|
- intended for places where one-pass kernel queries are materially better than repeated `ip -j` calls
|
||||||
|
|
||||||
|
This means the public API is:
|
||||||
|
|
||||||
|
- `get(...)` for the default backend
|
||||||
|
- `get_with_backend(Backend::Json | Backend::Netlink)` when backend choice matters
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
- `subcommands::address`
|
||||||
|
- typed address/interface-address data
|
||||||
|
- good place for subnet/broadcast derivation later
|
||||||
|
- `subcommands::link`
|
||||||
|
- typed link/interface data
|
||||||
|
- useful for `ifindex -> ifname` mapping and interface metadata
|
||||||
|
- `subcommands::neighbor`
|
||||||
|
- typed neighbor-table data
|
||||||
|
- currently the most useful experimental netlink surface
|
||||||
|
|
||||||
|
## Backend policy
|
||||||
|
|
||||||
|
JSON is the normal path.
|
||||||
|
|
||||||
|
Use netlink only when:
|
||||||
|
|
||||||
|
- the call is hot enough to matter
|
||||||
|
- repeated shelling out is obviously wasteful
|
||||||
|
- the netlink implementation is at least as coherent as the JSON one
|
||||||
|
|
||||||
|
Today that mainly applies to `neighbor::nl`.
|
||||||
|
|
||||||
|
## Relationship to wakey
|
||||||
|
|
||||||
|
`wakey` is the product.
|
||||||
|
|
||||||
|
`lda-ipjs` is a Linux networking adapter crate underneath it.
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
//! i also need to see devices and idk MAYBE maybe not MAYBE UHHHHHH maybe broadcast
|
//! i also need to see devices and idk MAYBE maybe not MAYBE UHHHHHH maybe broadcast
|
||||||
//!
|
//!
|
||||||
//! LOWK if this were to be calls to kernel or some bullshit then PLEASE because doing ts parsing its hell cuh
|
//! LOWK if this were to be calls to kernel or some bullshit then PLEASE because doing ts parsing its hell cuh
|
||||||
|
|
||||||
pub mod subcommands;
|
pub mod subcommands;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,241 @@
|
|||||||
//! ts
|
//! Typed wrappers for `ip -j address show`.
|
||||||
//!
|
//!
|
||||||
//! deals with both ip a (addroutput) and ip l (commonoutput)
|
//! This module is intentionally close to the Linux output shape while still
|
||||||
//!
|
//! tightening a few fields into more useful Rust types.
|
||||||
//! lowk why its free but its indirection and its ass
|
|
||||||
|
|
||||||
pub mod json;
|
pub mod json;
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
pub mod nl;
|
pub mod nl;
|
||||||
|
|
||||||
|
pub use crate::subcommands::Backend;
|
||||||
use crate::utils::serialize::mac::option_mac;
|
use crate::utils::serialize::mac::option_mac;
|
||||||
use macaddr::MacAddr;
|
use macaddr::MacAddr;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
use std::fmt;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
/// i dont include what i dont know about (almost all ts)
|
use crate::subcommands::link::OperState;
|
||||||
|
|
||||||
|
/// One interface row from `ip -j address show`.
|
||||||
#[derive(Serialize, Debug, Deserialize)]
|
#[derive(Serialize, Debug, Deserialize)]
|
||||||
pub struct AddrOutput {
|
pub struct AddrOutput {
|
||||||
pub ifindex: u32,
|
pub ifindex: u32,
|
||||||
pub ifname: String,
|
pub ifname: String,
|
||||||
/// i imagine UP or DOWN, unknown
|
/// Interface operational state.
|
||||||
pub operstate: String,
|
pub operstate: OperState,
|
||||||
// 6 has a serde and the enum doesnt? why. (serializing ts is ass although... im not given an array. they string formatted ts)
|
|
||||||
#[serde(with = "option_mac", default)]
|
#[serde(with = "option_mac", default)]
|
||||||
pub address: Option<MacAddr>,
|
pub address: Option<MacAddr>,
|
||||||
#[serde(default)] // i wish we have intellisense for this... fuck you metaprogramming
|
/// Per-address entries attached to this interface.
|
||||||
|
#[serde(default)]
|
||||||
pub addr_info: Vec<AddrInfo>,
|
pub addr_info: Vec<AddrInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// i be copying
|
/// One address entry nested under an interface row.
|
||||||
// Raw JSON shape from ip -j -4 address show
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct AddrInfo {
|
pub struct AddrInfo {
|
||||||
pub family: Option<String>,
|
pub family: Option<AddressFamily>,
|
||||||
|
|
||||||
pub local: Option<String>,
|
#[serde(flatten, default)]
|
||||||
pub prefixlen: Option<u8>,
|
pub cidr: InterfaceCidr,
|
||||||
|
|
||||||
pub broadcast: Option<String>,
|
pub broadcast: Option<Ipv4Addr>,
|
||||||
|
|
||||||
pub scope: Option<String>,
|
pub scope: Option<String>,
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
// many more exist; we only take what we need
|
}
|
||||||
|
|
||||||
|
/// Address family used by `ip address` output.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AddressFamily {
|
||||||
|
Inet,
|
||||||
|
Inet6,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AddressFamily {
|
||||||
|
pub fn parse_lossy(value: &str) -> Self {
|
||||||
|
match value.to_ascii_lowercase().as_str() {
|
||||||
|
"inet" => Self::Inet,
|
||||||
|
"inet6" => Self::Inet6,
|
||||||
|
_ => Self::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Inet => "inet",
|
||||||
|
Self::Inet6 => "inet6",
|
||||||
|
Self::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for AddressFamily {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for AddressFamily {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
Ok(Self::parse_lossy(&value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw parsed local address plus prefix length.
|
||||||
|
///
|
||||||
|
/// This is still a source-shaped type; callers that need a guaranteed usable
|
||||||
|
/// CIDR should validate that both fields are present.
|
||||||
|
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct InterfaceCidr {
|
||||||
|
pub local: Option<IpAddr>,
|
||||||
|
pub prefixlen: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InterfaceCidr {
|
||||||
|
/// Return whether both parts needed for a usable CIDR are present.
|
||||||
|
pub fn is_complete(&self) -> bool {
|
||||||
|
self.local.is_some() && self.prefixlen.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a validated complete CIDR when both fields are present.
|
||||||
|
pub fn complete(&self) -> Option<CompleteInterfaceCidr> {
|
||||||
|
Some(CompleteInterfaceCidr {
|
||||||
|
local: self.local?,
|
||||||
|
prefixlen: self.prefixlen?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the CIDR as `addr/prefixlen` when both fields are present.
|
||||||
|
pub fn to_cidr_string(&self) -> Option<String> {
|
||||||
|
self.complete().map(|cidr| cidr.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated interface CIDR with both local address and prefix length present.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct CompleteInterfaceCidr {
|
||||||
|
pub local: IpAddr,
|
||||||
|
pub prefixlen: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CompleteInterfaceCidr {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}/{}", self.local, self.prefixlen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AddrInfo {
|
||||||
|
/// Return the parsed local IP address when present.
|
||||||
|
pub fn local_addr(&self) -> Option<IpAddr> {
|
||||||
|
self.cidr.local
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the parsed prefix length when present.
|
||||||
|
pub fn prefixlen(&self) -> Option<u8> {
|
||||||
|
self.cidr.prefixlen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return whether this row is IPv4.
|
||||||
|
pub fn is_ipv4(&self) -> bool {
|
||||||
|
matches!(self.family, Some(AddressFamily::Inet))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return whether this row is IPv6.
|
||||||
|
pub fn is_ipv6(&self) -> bool {
|
||||||
|
matches!(self.family, Some(AddressFamily::Inet6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AddrOutput {
|
||||||
|
/// Iterate IPv4 address entries.
|
||||||
|
pub fn ipv4_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
|
||||||
|
self.addr_info.iter().filter(|info| info.is_ipv4())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate IPv6 address entries.
|
||||||
|
pub fn ipv6_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
|
||||||
|
self.addr_info.iter().filter(|info| info.is_ipv6())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch address data using the default backend.
|
||||||
|
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
|
||||||
|
get_with_backend(Backend::Json, dev).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch address data using an explicit backend.
|
||||||
|
pub async fn get_with_backend(
|
||||||
|
backend: Backend,
|
||||||
|
dev: Option<&str>,
|
||||||
|
) -> anyhow::Result<Vec<AddrOutput>> {
|
||||||
|
match backend {
|
||||||
|
Backend::Json => json::get(dev).await,
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
Backend::Netlink => nl::get(dev).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
|
use super::{AddrInfo, AddrOutput, AddressFamily, InterfaceCidr};
|
||||||
|
use crate::subcommands::link::OperState;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn address_family_parses_known_values() {
|
||||||
|
assert_eq!(AddressFamily::parse_lossy("inet"), AddressFamily::Inet);
|
||||||
|
assert_eq!(AddressFamily::parse_lossy("INET6"), AddressFamily::Inet6);
|
||||||
|
assert_eq!(AddressFamily::parse_lossy("weird"), AddressFamily::Other);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addr_info_deserializes_typed_ip_fields() {
|
||||||
|
let info: AddrInfo = serde_json::from_str(
|
||||||
|
r#"{"family":"inet","local":"192.168.1.1","prefixlen":24,"broadcast":"192.168.1.255","scope":"global"}"#,
|
||||||
|
)
|
||||||
|
.expect("addr_info json should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(info.family, Some(AddressFamily::Inet));
|
||||||
|
assert_eq!(
|
||||||
|
info.local_addr(),
|
||||||
|
Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))
|
||||||
|
);
|
||||||
|
assert_eq!(info.prefixlen(), Some(24));
|
||||||
|
assert_eq!(info.broadcast, Some(Ipv4Addr::new(192, 168, 1, 255)));
|
||||||
|
assert!(info.cidr.is_complete());
|
||||||
|
assert_eq!(
|
||||||
|
info.cidr.to_cidr_string().as_deref(),
|
||||||
|
Some("192.168.1.1/24")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addr_output_deserializes_typed_operstate() {
|
||||||
|
let output: AddrOutput = serde_json::from_str(
|
||||||
|
r#"{"ifindex":2,"ifname":"br-lan","operstate":"UP","address":"aa:bb:cc:dd:ee:ff","addr_info":[]}"#,
|
||||||
|
)
|
||||||
|
.expect("addr_output json should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(output.operstate, OperState::Up);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interface_cidr_complete_formats() {
|
||||||
|
let cidr = InterfaceCidr {
|
||||||
|
local: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
|
||||||
|
prefixlen: Some(16),
|
||||||
|
};
|
||||||
|
|
||||||
|
let complete = cidr.complete().expect("cidr should be complete");
|
||||||
|
assert_eq!(complete.to_string(), "10.0.0.1/16");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,109 @@
|
|||||||
//! i said i aint doing ts no more why am i still here
|
//! i said i aint doing ts no more why am i still here
|
||||||
|
#![cfg(unix)]
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
|
use rtnetlink::Handle;
|
||||||
|
use rtnetlink::packet_route::{AddressFamily, address::AddressAttribute};
|
||||||
|
|
||||||
use crate::subcommands::address::AddrOutput;
|
use crate::subcommands::{
|
||||||
|
address::{AddrInfo, AddrOutput, AddressFamily as IpAddressFamily, InterfaceCidr},
|
||||||
|
link,
|
||||||
|
};
|
||||||
|
|
||||||
// shit this one is even worse you needa collect info from two places
|
|
||||||
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
|
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
|
||||||
let (conn, handle, _) = rtnetlink::new_connection()?;
|
let (conn, handle, _) = rtnetlink::new_connection()?;
|
||||||
tokio::spawn(conn); // every time?
|
tokio::spawn(conn);
|
||||||
let mut address = handle.address().get();
|
|
||||||
let mut link = handle.link().get();
|
get_with_handle(&handle, dev).await
|
||||||
if let Some(dev) = dev {
|
}
|
||||||
link = link.match_name(dev.to_owned());
|
|
||||||
if let Some(ind) = link.execute().try_next().await?.map(|a| a.header.index) {
|
pub async fn get_with_handle(
|
||||||
address = address.set_link_index_filter(ind);
|
handle: &Handle,
|
||||||
}
|
dev: Option<&str>,
|
||||||
};
|
) -> anyhow::Result<Vec<AddrOutput>> {
|
||||||
address.execute().try_next().await?;
|
let links = link::nl::get_with_handle(handle, dev).await?;
|
||||||
todo!()
|
let link_by_index: BTreeMap<u32, crate::subcommands::link::LinkOutput> =
|
||||||
|
links.into_iter().map(|link| (link.ifindex, link)).collect();
|
||||||
|
|
||||||
|
let mut address = handle.address().get();
|
||||||
|
if let Some(dev) = dev {
|
||||||
|
if let Some(index) = link_by_index
|
||||||
|
.values()
|
||||||
|
.find(|link| link.ifname == dev)
|
||||||
|
.map(|link| link.ifindex)
|
||||||
|
{
|
||||||
|
address = address.set_link_index_filter(index);
|
||||||
|
} else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stream = address.execute();
|
||||||
|
let mut addr_info_by_index: BTreeMap<u32, Vec<AddrInfo>> = BTreeMap::new();
|
||||||
|
|
||||||
|
while let Some(msg) = stream.try_next().await? {
|
||||||
|
if !matches!(
|
||||||
|
msg.header.family,
|
||||||
|
AddressFamily::Inet | AddressFamily::Inet6
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let family = match msg.header.family {
|
||||||
|
AddressFamily::Inet => Some(IpAddressFamily::Inet),
|
||||||
|
AddressFamily::Inet6 => Some(IpAddressFamily::Inet6),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut local = None;
|
||||||
|
let mut prefixlen = Some(msg.header.prefix_len);
|
||||||
|
let mut broadcast = None;
|
||||||
|
let mut scope = Some(format!("{:?}", msg.header.scope).to_lowercase());
|
||||||
|
let mut label = None;
|
||||||
|
|
||||||
|
for attr in msg.attributes {
|
||||||
|
match attr {
|
||||||
|
AddressAttribute::Address(addr) | AddressAttribute::Local(addr) => {
|
||||||
|
if local.is_none() {
|
||||||
|
local = addr.to_string().parse().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddressAttribute::Broadcast(addr) => {
|
||||||
|
broadcast = addr.to_string().parse().ok();
|
||||||
|
}
|
||||||
|
AddressAttribute::Label(name) => {
|
||||||
|
label = Some(name);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addr_info_by_index
|
||||||
|
.entry(msg.header.index)
|
||||||
|
.or_default()
|
||||||
|
.push(AddrInfo {
|
||||||
|
family,
|
||||||
|
cidr: InterfaceCidr {
|
||||||
|
local,
|
||||||
|
prefixlen: prefixlen.take(),
|
||||||
|
},
|
||||||
|
broadcast,
|
||||||
|
scope: scope.take(),
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = link_by_index
|
||||||
|
.into_values()
|
||||||
|
.map(|link| AddrOutput {
|
||||||
|
ifindex: link.ifindex,
|
||||||
|
ifname: link.ifname,
|
||||||
|
operstate: link.operstate.unwrap_or(link::OperState::Unknown),
|
||||||
|
address: link.address,
|
||||||
|
addr_info: addr_info_by_index.remove(&link.ifindex).unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use std::{io, process::Output};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
use super::LinkOutput;
|
||||||
|
|
||||||
|
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
|
||||||
|
let output = _get(dev).await.context("Can not run command")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!(String::from_utf8_lossy(&output.stderr).into_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_slice(&output.stdout).context("Deserialize failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn _get(dev: Option<&str>) -> io::Result<Output> {
|
||||||
|
let mut cmd = tokio::process::Command::new("ip");
|
||||||
|
cmd.args(["-j", "link", "show"]);
|
||||||
|
|
||||||
|
if let Some(d) = dev {
|
||||||
|
cmd.arg(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.output().await
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//! Typed wrappers for `ip -j link show`.
|
||||||
|
|
||||||
|
pub mod json;
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
pub mod nl;
|
||||||
|
|
||||||
|
pub use crate::subcommands::Backend;
|
||||||
|
use crate::utils::serialize::mac::option_mac;
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
use rtnetlink::packet_route::link::State as NetlinkOperState;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
|
/// One interface row from `ip -j link show`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LinkOutput {
|
||||||
|
pub ifindex: u32,
|
||||||
|
pub ifname: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub operstate: Option<OperState>,
|
||||||
|
#[serde(default, with = "option_mac")]
|
||||||
|
pub address: Option<MacAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Operational state of a Linux network interface.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OperState {
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
Unknown,
|
||||||
|
Dormant,
|
||||||
|
LowerLayerDown,
|
||||||
|
NotPresent,
|
||||||
|
Testing,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OperState {
|
||||||
|
pub fn parse_lossy(value: &str) -> Self {
|
||||||
|
match value.to_ascii_uppercase().as_str() {
|
||||||
|
"UP" => Self::Up,
|
||||||
|
"DOWN" => Self::Down,
|
||||||
|
"UNKNOWN" => Self::Unknown,
|
||||||
|
"DORMANT" => Self::Dormant,
|
||||||
|
"LOWERLAYERDOWN" | "LOWER_LAYER_DOWN" | "LOWERLAYER_DOWN" => Self::LowerLayerDown,
|
||||||
|
"NOTPRESENT" | "NOT_PRESENT" => Self::NotPresent,
|
||||||
|
"TESTING" => Self::Testing,
|
||||||
|
_ => Self::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Up => "UP",
|
||||||
|
Self::Down => "DOWN",
|
||||||
|
Self::Unknown => "UNKNOWN",
|
||||||
|
Self::Dormant => "DORMANT",
|
||||||
|
Self::LowerLayerDown => "LOWERLAYERDOWN",
|
||||||
|
Self::NotPresent => "NOTPRESENT",
|
||||||
|
Self::Testing => "TESTING",
|
||||||
|
Self::Other => "OTHER",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_up(self) -> bool {
|
||||||
|
matches!(self, Self::Up)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for OperState {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for OperState {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
Ok(Self::parse_lossy(&value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
impl From<NetlinkOperState> for OperState {
|
||||||
|
fn from(value: NetlinkOperState) -> Self {
|
||||||
|
match value {
|
||||||
|
NetlinkOperState::Up => Self::Up,
|
||||||
|
NetlinkOperState::Down => Self::Down,
|
||||||
|
NetlinkOperState::Unknown => Self::Unknown,
|
||||||
|
NetlinkOperState::Dormant => Self::Dormant,
|
||||||
|
NetlinkOperState::LowerLayerDown => Self::LowerLayerDown,
|
||||||
|
NetlinkOperState::NotPresent => Self::NotPresent,
|
||||||
|
NetlinkOperState::Testing => Self::Testing,
|
||||||
|
_ => Self::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch link rows using the default backend.
|
||||||
|
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
|
||||||
|
get_with_backend(Backend::Json, dev).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch link rows using an explicit backend.
|
||||||
|
pub async fn get_with_backend(
|
||||||
|
backend: Backend,
|
||||||
|
dev: Option<&str>,
|
||||||
|
) -> anyhow::Result<Vec<LinkOutput>> {
|
||||||
|
match backend {
|
||||||
|
Backend::Json => json::get(dev).await,
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
Backend::Netlink => nl::get(dev).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{LinkOutput, OperState};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operstate_parses_json_and_netlink_spellings() {
|
||||||
|
assert_eq!(OperState::parse_lossy("UP"), OperState::Up);
|
||||||
|
assert_eq!(OperState::parse_lossy("Up"), OperState::Up);
|
||||||
|
assert_eq!(
|
||||||
|
OperState::parse_lossy("LOWERLAYERDOWN"),
|
||||||
|
OperState::LowerLayerDown
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
OperState::parse_lossy("LowerLayerDown"),
|
||||||
|
OperState::LowerLayerDown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn link_output_deserializes_typed_operstate() {
|
||||||
|
let link: LinkOutput = serde_json::from_str(
|
||||||
|
r#"{"ifindex":1,"ifname":"eth0","operstate":"UP","address":"aa:bb:cc:dd:ee:ff"}"#,
|
||||||
|
)
|
||||||
|
.expect("link json should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(link.operstate, Some(OperState::Up));
|
||||||
|
assert_eq!(
|
||||||
|
link.address.map(|mac| mac.to_string()).as_deref(),
|
||||||
|
Some("aa:bb:cc:dd:ee:ff")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#![cfg(unix)]
|
||||||
|
|
||||||
|
use futures::TryStreamExt;
|
||||||
|
use rtnetlink::Handle;
|
||||||
|
use rtnetlink::packet_route::link::LinkAttribute;
|
||||||
|
|
||||||
|
use super::LinkOutput;
|
||||||
|
|
||||||
|
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
|
||||||
|
let (conn, handle, _) = rtnetlink::new_connection()?;
|
||||||
|
tokio::spawn(conn);
|
||||||
|
|
||||||
|
get_with_handle(&handle, dev).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_with_handle(
|
||||||
|
handle: &Handle,
|
||||||
|
dev: Option<&str>,
|
||||||
|
) -> anyhow::Result<Vec<LinkOutput>> {
|
||||||
|
let mut req = handle.link().get();
|
||||||
|
if let Some(dev) = dev {
|
||||||
|
req = req.match_name(dev.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stream = req.execute();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
while let Some(link) = stream.try_next().await? {
|
||||||
|
let mut ifname = None;
|
||||||
|
let mut operstate = None;
|
||||||
|
let mut address = None;
|
||||||
|
|
||||||
|
for attr in link.attributes {
|
||||||
|
match attr {
|
||||||
|
LinkAttribute::IfName(name) => ifname = Some(name),
|
||||||
|
LinkAttribute::Address(bytes) => {
|
||||||
|
address = match bytes.len() {
|
||||||
|
6 => bytes.first_chunk::<6>().map(|&b| macaddr::MacAddr::from(b)),
|
||||||
|
8 => bytes.first_chunk::<8>().map(|&b| macaddr::MacAddr::from(b)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LinkAttribute::OperState(state) => operstate = Some(state.into()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ifname) = ifname {
|
||||||
|
out.push(LinkOutput {
|
||||||
|
ifindex: link.header.index,
|
||||||
|
ifname,
|
||||||
|
operstate,
|
||||||
|
address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
@@ -1,2 +1,10 @@
|
|||||||
pub mod address;
|
pub mod address;
|
||||||
|
pub mod link;
|
||||||
pub mod neighbor;
|
pub mod neighbor;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Backend {
|
||||||
|
Json,
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
Netlink,
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
//! ```bash
|
//! Typed wrappers for `ip -j neigh show`.
|
||||||
//! ip -j n s
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! yes. this is a real call.
|
|
||||||
|
|
||||||
pub mod json;
|
pub mod json;
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
pub mod nl;
|
pub mod nl;
|
||||||
|
|
||||||
|
pub use crate::subcommands::Backend;
|
||||||
use crate::utils::serialize::mac::option_mac;
|
use crate::utils::serialize::mac::option_mac;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
||||||
@@ -14,18 +12,18 @@ use macaddr::MacAddr;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use strum::{Display, EnumString};
|
use strum::{Display, EnumString};
|
||||||
|
|
||||||
|
/// Structured neighbor query input matching the common `ip neigh` flags.
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
||||||
pub struct NeighborInput {
|
pub struct NeighborInput {
|
||||||
/// supports only the last item (ignore), `to` keyword is optional
|
/// Destination address filter. The optional `to` keyword in CLI form is implicit here.
|
||||||
pub to: Option<IpAddr>,
|
pub to: Option<IpAddr>,
|
||||||
/// supports only one item (it complains if multiple)
|
/// Interface-name filter.
|
||||||
pub dev: Option<String>, // im all for simplicity
|
pub dev: Option<String>,
|
||||||
/// takes multiple, has to have `nud` before bro or it will think you `to`
|
/// Neighbor-state filters.
|
||||||
pub nud: Vec<NUDState>,
|
pub nud: Vec<NUDState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// as input this must be lowercase. as output it is uppercase
|
/// Linux neighbor reachability states.
|
||||||
/// docs for items come from a random ahh man website idk
|
|
||||||
#[derive(
|
#[derive(
|
||||||
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, Serialize, Deserialize, Default,
|
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, Serialize, Deserialize, Default,
|
||||||
)]
|
)]
|
||||||
@@ -68,7 +66,7 @@ pub enum NUDState {
|
|||||||
Other(u16),
|
Other(u16),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// everything i see
|
/// One neighbor row from `ip -j neigh show`.
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
||||||
pub struct NeighborItem {
|
pub struct NeighborItem {
|
||||||
#[serde(rename(deserialize = "dst"))]
|
#[serde(rename(deserialize = "dst"))]
|
||||||
@@ -80,3 +78,30 @@ pub struct NeighborItem {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub state: Vec<NUDState>,
|
pub state: Vec<NUDState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch neighbor rows using the default backend.
|
||||||
|
pub async fn get(
|
||||||
|
ip: Option<IpAddr>,
|
||||||
|
dev: Option<&str>,
|
||||||
|
nud: &[NUDState],
|
||||||
|
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||||
|
get_with_backend(Backend::Json, ip, dev, nud).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch neighbor rows using an explicit backend.
|
||||||
|
pub async fn get_with_backend(
|
||||||
|
backend: Backend,
|
||||||
|
ip: Option<IpAddr>,
|
||||||
|
dev: Option<&str>,
|
||||||
|
nud: &[NUDState],
|
||||||
|
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||||
|
match backend {
|
||||||
|
Backend::Json => json::get(ip, dev, nud).await,
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
Backend::Netlink => {
|
||||||
|
let ips: Vec<IpAddr> = ip.into_iter().collect();
|
||||||
|
let devs: Vec<&str> = dev.into_iter().collect();
|
||||||
|
nl::get(&ips, &devs, nud, &[]).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
//! this is purely experimental. im not doing ts no mo
|
//! rtnetlink-based neighbor table query. One syscall, filter in userspace.
|
||||||
|
#![cfg(unix)]
|
||||||
// hallo
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
net::IpAddr,
|
net::IpAddr,
|
||||||
@@ -8,123 +7,115 @@ use std::{
|
|||||||
|
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
use macaddr::MacAddr;
|
use macaddr::MacAddr;
|
||||||
|
use rtnetlink::Handle;
|
||||||
use rtnetlink::packet_route::{
|
use rtnetlink::packet_route::{
|
||||||
AddressFamily,
|
AddressFamily,
|
||||||
link::LinkAttribute,
|
|
||||||
neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourState},
|
neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourState},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{NUDState, NeighborItem};
|
use super::{NUDState, NeighborItem};
|
||||||
|
use crate::subcommands::link;
|
||||||
|
|
||||||
// dont you love https://github.com/rust-netlink/rtnetlink/blob/main/examples/get_neighbours.rs
|
/// Fetch neighbors via rtnetlink. Empty slice = no filter (match all).
|
||||||
// NeighborItem.state guarantees to be a single thing.
|
/// Non-empty slice = match ANY in the set.
|
||||||
|
// https://github.com/rust-netlink/rtnetlink/blob/main/examples/get_neighbours.rs
|
||||||
pub async fn get(
|
pub async fn get(
|
||||||
ip: Option<IpAddr>,
|
ips: &[IpAddr],
|
||||||
dev: Option<&str>,
|
devs: &[impl AsRef<str>],
|
||||||
nud: &[NUDState],
|
nuds: &[NUDState],
|
||||||
|
macs: &[MacAddr],
|
||||||
) -> anyhow::Result<Vec<NeighborItem>> {
|
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||||
let (gip, gdev, gnud) = (ip, dev, nud);
|
|
||||||
let (conn, handle, _) = rtnetlink::new_connection()?;
|
let (conn, handle, _) = rtnetlink::new_connection()?;
|
||||||
tokio::spawn(conn); // every time?
|
tokio::spawn(conn);
|
||||||
let mut neighbor_data = handle.neighbours().get().execute();
|
|
||||||
let nudset: HashSet<&NUDState> = HashSet::from_iter(gnud);
|
|
||||||
|
|
||||||
// map ifindex to name
|
get_with_handle(&handle, ips, devs, nuds, macs).await
|
||||||
let mut ball: HashMap<u32, String> = HashMap::new();
|
}
|
||||||
|
|
||||||
|
pub async fn get_with_handle(
|
||||||
|
handle: &Handle,
|
||||||
|
ips: &[IpAddr],
|
||||||
|
devs: &[impl AsRef<str>],
|
||||||
|
nuds: &[NUDState],
|
||||||
|
macs: &[MacAddr],
|
||||||
|
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||||
|
// Build filter sets (empty = match all)
|
||||||
|
let ip_set: HashSet<IpAddr> = ips.iter().copied().collect();
|
||||||
|
let dev_set: HashSet<&str> = devs.iter().map(AsRef::as_ref).collect();
|
||||||
|
let nud_set: HashSet<&NUDState> = nuds.iter().collect();
|
||||||
|
let mac_set: HashSet<MacAddr> = macs.iter().copied().collect();
|
||||||
|
|
||||||
|
// Prefetch all links once; link lookups were the ugliest and most expensive part.
|
||||||
|
let ifname_cache: HashMap<u32, String> = link::nl::get_with_handle(handle, None)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|link| (link.ifindex, link.ifname))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut neighbor_data = handle.neighbours().get().execute();
|
||||||
let mut result = vec![];
|
let mut result = vec![];
|
||||||
'big: while let Some(neighbour_message_item) = neighbor_data.try_next().await? {
|
|
||||||
// Filter by address family
|
'row: while let Some(msg) = neighbor_data.try_next().await? {
|
||||||
|
// Only IPv4/IPv6, skip NOARP
|
||||||
if !matches!(
|
if !matches!(
|
||||||
neighbour_message_item.header.family,
|
msg.header.family,
|
||||||
AddressFamily::Inet | AddressFamily::Inet6
|
AddressFamily::Inet | AddressFamily::Inet6
|
||||||
) || matches!(neighbour_message_item.header.state, NeighbourState::Noarp)
|
) || matches!(msg.header.state, NeighbourState::Noarp)
|
||||||
// copilot says this to match ip -j n s
|
|
||||||
{
|
{
|
||||||
continue 'big;
|
continue 'row;
|
||||||
}
|
}
|
||||||
|
|
||||||
let state = vec![
|
let state: NUDState = msg.header.state.try_into().unwrap_or_default();
|
||||||
neighbour_message_item
|
|
||||||
.header
|
|
||||||
.state
|
|
||||||
.try_into()
|
|
||||||
.unwrap_or_default(),
|
|
||||||
]; // ONE ITEM. why tf ts design json.
|
|
||||||
let mut ip = None;
|
let mut ip = None;
|
||||||
let mut mac = None;
|
let mut mac = None;
|
||||||
|
|
||||||
for neigh_attr in neighbour_message_item.attributes {
|
for attr in msg.attributes {
|
||||||
match neigh_attr {
|
match attr {
|
||||||
NeighbourAttribute::Destination(neighbour_address) => match neighbour_address {
|
NeighbourAttribute::Destination(addr) => match addr {
|
||||||
NeighbourAddress::Inet(ipv4_addr) => ip = Some(ipv4_addr.into()),
|
NeighbourAddress::Inet(v4) => ip = Some(IpAddr::from(v4)),
|
||||||
NeighbourAddress::Inet6(ipv6_addr) => ip = Some(ipv6_addr.into()),
|
NeighbourAddress::Inet6(v6) => ip = Some(IpAddr::from(v6)),
|
||||||
_ => continue 'big,
|
_ => continue 'row,
|
||||||
},
|
},
|
||||||
NeighbourAttribute::LinkLocalAddress(items) => {
|
NeighbourAttribute::LinkLocalAddress(bytes) => {
|
||||||
mac = match items.len() {
|
mac = match bytes.len() {
|
||||||
6 => items.first_chunk::<6>().map(|&e| MacAddr::from(e)),
|
6 => bytes.first_chunk::<6>().map(|&b| MacAddr::from(b)),
|
||||||
8 => items.first_chunk::<8>().map(|&e| MacAddr::from(e)),
|
8 => bytes.first_chunk::<8>().map(|&b| MacAddr::from(b)),
|
||||||
_ => continue 'big,
|
_ => continue 'row,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => continue,
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// exquisite
|
// Resolve ifindex -> name (cached)
|
||||||
let dev = if let Some(cached) = ball.get(&neighbour_message_item.header.ifindex) {
|
let dev = ifname_cache.get(&msg.header.ifindex).cloned();
|
||||||
Some(cached.clone())
|
|
||||||
} else {
|
|
||||||
// Query and cache
|
|
||||||
let name = handle
|
|
||||||
.link()
|
|
||||||
.get()
|
|
||||||
.match_index(neighbour_message_item.header.ifindex)
|
|
||||||
.execute()
|
|
||||||
.try_next()
|
|
||||||
.await?
|
|
||||||
.and_then(|a| {
|
|
||||||
a.attributes.into_iter().find_map(|attr| match attr {
|
|
||||||
LinkAttribute::IfName(name) => Some(name),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(ref n) = name {
|
|
||||||
ball.insert(neighbour_message_item.header.ifindex, n.clone());
|
|
||||||
}
|
|
||||||
name
|
|
||||||
};
|
|
||||||
|
|
||||||
let (Some(ip), Some(dev)) = (ip, dev) else {
|
let (Some(ip), Some(dev)) = (ip, dev) else {
|
||||||
continue 'big;
|
continue 'row;
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
// Apply filters (empty set = match all)
|
||||||
// low block
|
if !ip_set.is_empty() && !ip_set.contains(&ip) {
|
||||||
if let Some(fip) = gip
|
continue 'row;
|
||||||
&& fip != ip
|
}
|
||||||
{
|
if !dev_set.is_empty() && !dev_set.contains(dev.as_str()) {
|
||||||
continue 'big;
|
continue 'row;
|
||||||
}
|
}
|
||||||
if let Some(fdev) = gdev
|
if !nud_set.is_empty() && !nud_set.contains(&state) {
|
||||||
&& dev != fdev
|
continue 'row;
|
||||||
{
|
}
|
||||||
continue 'big;
|
if !mac_set.is_empty() && !mac.is_some_and(|m| mac_set.contains(&m)) {
|
||||||
}
|
continue 'row;
|
||||||
if !nudset.is_empty() && !nudset.contains(&state[0]) {
|
|
||||||
continue 'big;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push(NeighborItem {
|
result.push(NeighborItem {
|
||||||
ip,
|
ip,
|
||||||
dev: Some(dev),
|
dev: Some(dev),
|
||||||
mac,
|
mac,
|
||||||
state,
|
state: vec![state],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(result) // now i need another pass to filter out the uh.
|
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<NeighbourState> for NUDState {
|
impl TryFrom<NeighbourState> for NUDState {
|
||||||
@@ -140,7 +131,7 @@ impl TryFrom<NeighbourState> for NUDState {
|
|||||||
NeighbourState::Permanent => Ok(Self::Permanent),
|
NeighbourState::Permanent => Ok(Self::Permanent),
|
||||||
NeighbourState::None => Ok(Self::None),
|
NeighbourState::None => Ok(Self::None),
|
||||||
NeighbourState::Other(e) => Ok(Self::Other(e)),
|
NeighbourState::Other(e) => Ok(Self::Other(e)),
|
||||||
_ => Err(u16::MAX), // idk
|
_ => Err(u16::MAX),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-21
@@ -2,16 +2,17 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
use lda_ipjs::subcommands::{address, neighbor};
|
use lda_ipjs::subcommands::{address, neighbor};
|
||||||
|
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
#[tokio::test] // ← Use tokio::test instead of manual #[tokio::main]
|
#[tokio::test] // ← Use tokio::test instead of manual #[tokio::main]
|
||||||
async fn ball1() -> anyhow::Result<()> {
|
async fn ball1() -> anyhow::Result<()> {
|
||||||
let result = neighbor::nl::get(None, None, &[]).await?;
|
let result = neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
|
||||||
println!("netlink results: {:?}", result);
|
println!("netlink results: {:?}", result);
|
||||||
Ok(()) // ← Don't force error, let it succeed
|
Ok(()) // ← Don't force error, let it succeed
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ball2() -> anyhow::Result<()> {
|
async fn ball2() -> anyhow::Result<()> {
|
||||||
let result = neighbor::json::get(None, None, &[]).await?;
|
let result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
|
||||||
println!("json results: {:?}", result);
|
println!("json results: {:?}", result);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -91,30 +92,34 @@ impl TypeName for serde_json::Value {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ball_compare_backends() -> anyhow::Result<()> {
|
async fn ball_compare_backends() -> anyhow::Result<()> {
|
||||||
println!("=== JSON Backend ===");
|
println!("=== JSON Backend ===");
|
||||||
let json_result = neighbor::json::get(None, None, &[]).await?;
|
let json_result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
|
||||||
println!("Got {} entries from JSON", json_result.len());
|
println!("Got {} entries from JSON", json_result.len());
|
||||||
|
|
||||||
println!("\n=== Netlink Backend ===");
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
let nl_result = neighbor::nl::get(None, None, &[]).await?;
|
{
|
||||||
println!("Got {} entries from netlink", nl_result.len());
|
println!("\n=== Netlink Backend ===");
|
||||||
|
let nl_result =
|
||||||
|
neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
|
||||||
|
println!("Got {} entries from netlink", nl_result.len());
|
||||||
|
|
||||||
// Compare counts
|
// Compare counts
|
||||||
if json_result.len() != nl_result.len() {
|
if json_result.len() != nl_result.len() {
|
||||||
|
println!(
|
||||||
|
"\n⚠️ Count mismatch! JSON: {}, Netlink: {}",
|
||||||
|
json_result.len(),
|
||||||
|
nl_result.len()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("\n✅ Both backends returned same count");
|
||||||
|
}
|
||||||
|
let a: HashSet<neighbor::NeighborItem> = HashSet::from_iter(json_result);
|
||||||
|
let b: HashSet<neighbor::NeighborItem> = HashSet::from_iter(nl_result);
|
||||||
println!(
|
println!(
|
||||||
"\n⚠️ Count mismatch! JSON: {}, Netlink: {}",
|
"istg {len1} == {len2} or else",
|
||||||
json_result.len(),
|
len1 = a.len(),
|
||||||
nl_result.len()
|
len2 = b.len()
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
println!("\n✅ Both backends returned same count");
|
|
||||||
}
|
}
|
||||||
let a: HashSet<neighbor::NeighborItem> = HashSet::from_iter(json_result);
|
|
||||||
let b: HashSet<neighbor::NeighborItem> = HashSet::from_iter(nl_result);
|
|
||||||
println!(
|
|
||||||
"istg {len1} == {len2} or else",
|
|
||||||
len1 = a.len(),
|
|
||||||
len2 = b.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -126,7 +131,7 @@ async fn ball_compare_backends() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ipjas() -> anyhow::Result<()> {
|
async fn ipjas() -> anyhow::Result<()> {
|
||||||
let cuh = address::json::get(None).await?;
|
let cuh = address::get_with_backend(address::Backend::Json, None).await?;
|
||||||
println!("{cuh:#?}");
|
println!("{cuh:#?}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# this does one thing
|
||||||
|
param(
|
||||||
|
[string]$PASSWD
|
||||||
|
)
|
||||||
|
. "$(Split-Path -Parent $PSScriptRoot)/scripts/lib.ps1"
|
||||||
|
|
||||||
|
$PASSWD = Get-DefaultPassword $PASSWD
|
||||||
|
|
||||||
|
# idk what this does it works like that then thats how it is
|
||||||
|
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/ldlda_help $PSScriptRoot
|
||||||
|
pscp.exe -l root -scp -pw $PASSWD 192.168.100.1:/etc/rc.local $PSScriptRoot
|
||||||
|
|
||||||
|
$initDir = Join-Path $PSScriptRoot 'init.d'
|
||||||
|
New-Item -ItemType Directory -Force -Path $initDir | Out-Null
|
||||||
|
|
||||||
|
# these are sum
|
||||||
|
$files = "update_wakey" , "wakey" , "update_tailscale" , "wireguard_setup"
|
||||||
|
$remote = $files.ForEach({ "/etc/init.d/$_" })
|
||||||
|
$remote | ForEach-Object {
|
||||||
|
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$initDir\"
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ try { $PSStyle.OutputRendering = 'Host' } catch {}
|
|||||||
|
|
||||||
. "$PSScriptRoot/lib.ps1"
|
. "$PSScriptRoot/lib.ps1"
|
||||||
|
|
||||||
|
$Pass = Get-DefaultPassword $Pass
|
||||||
|
|
||||||
function Get-DeployScript {
|
function Get-DeployScript {
|
||||||
param($DeployPreferred, $DeployTmp, $RemoteTmp, $RemotePath, $RestartFlag)
|
param($DeployPreferred, $DeployTmp, $RemoteTmp, $RemotePath, $RestartFlag)
|
||||||
return @"
|
return @"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ BIN=/root/.bin/wakey
|
|||||||
|
|
||||||
start_service() {
|
start_service() {
|
||||||
procd_open_instance
|
procd_open_instance
|
||||||
procd_set_param command "$BIN"
|
procd_set_param command "$BIN" http --host :: --port 12012
|
||||||
procd_set_param respawn 5 1 0
|
procd_set_param respawn 5 1 0
|
||||||
procd_set_param stdout 1
|
procd_set_param stdout 1
|
||||||
procd_set_param stderr 1
|
procd_set_param stderr 1
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ latest_asset_url() {
|
|||||||
HOST=$1 OWNER=$2 REPO=$3 ARCH=$4
|
HOST=$1 OWNER=$2 REPO=$3 ARCH=$4
|
||||||
API="https://$HOST/api/v1/repos/$OWNER/$REPO/releases/latest"
|
API="https://$HOST/api/v1/repos/$OWNER/$REPO/releases/latest"
|
||||||
RESP=$(http_get "$API") || return 1
|
RESP=$(http_get "$API") || return 1
|
||||||
# Extract the first browser_download_url ending with our ARCH .tgz (BusyBox-friendly)
|
# Extract the LAST browser_download_url ending with our ARCH .tgz (BusyBox-friendly)
|
||||||
echo "$RESP" |
|
echo "$RESP" |
|
||||||
grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$ARCH"'\.tgz"' |
|
grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$ARCH"'\.tgz"' |
|
||||||
head -n1 |
|
tail -n1 |
|
||||||
cut -d '"' -f 4
|
cut -d '"' -f 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+72
-5
@@ -1,5 +1,70 @@
|
|||||||
# Shared functions for wakey scripts
|
# Shared functions for wakey scripts
|
||||||
|
|
||||||
|
function Get-DefaultPassword {
|
||||||
|
param([string]$Password)
|
||||||
|
|
||||||
|
if ($Password) {
|
||||||
|
return $Password
|
||||||
|
}
|
||||||
|
|
||||||
|
$pwPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'ar_data/pw'
|
||||||
|
if (-not (Test-Path $pwPath)) {
|
||||||
|
throw "Password not provided and default file not found: $pwPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Get-Content -Raw $pwPath).Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Normalize-LineEndings {
|
||||||
|
param([string]$Text)
|
||||||
|
if ($null -eq $Text) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
return ($Text -replace "`r`n", "`n" -replace "`r", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Quote-ShArg {
|
||||||
|
param([AllowNull()][string]$Value)
|
||||||
|
if ($null -eq $Value) {
|
||||||
|
return "''"
|
||||||
|
}
|
||||||
|
return "'" + ($Value -replace "'", ("'" + '"' + "'" + '"' + "'")) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Normalize-PosixPath {
|
||||||
|
param([string]$Path)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||||
|
return $Path
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = $Path -replace '\\', '/'
|
||||||
|
if ($normalized.Length -gt 1) {
|
||||||
|
$normalized = $normalized -replace '/+', '/'
|
||||||
|
}
|
||||||
|
return $normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function Join-PosixPath {
|
||||||
|
param(
|
||||||
|
[string]$Left,
|
||||||
|
[string]$Right
|
||||||
|
)
|
||||||
|
|
||||||
|
$leftNorm = Normalize-PosixPath $Left
|
||||||
|
$rightNorm = Normalize-PosixPath $Right
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($leftNorm)) {
|
||||||
|
return $rightNorm
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($rightNorm)) {
|
||||||
|
return $leftNorm
|
||||||
|
}
|
||||||
|
|
||||||
|
$leftTrim = $leftNorm.TrimEnd('/')
|
||||||
|
$rightTrim = $rightNorm.TrimStart('/')
|
||||||
|
return "$leftTrim/$rightTrim"
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-Ext {
|
function Invoke-Ext {
|
||||||
param($Exe, $Arguments, $Label)
|
param($Exe, $Arguments, $Label)
|
||||||
$displayArgs = $Arguments.Clone()
|
$displayArgs = $Arguments.Clone()
|
||||||
@@ -12,8 +77,8 @@ function Invoke-Ext {
|
|||||||
|
|
||||||
$out = & $Exe @Arguments 2>&1
|
$out = & $Exe @Arguments 2>&1
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $LASTEXITCODE, ($out -join "`n"))
|
Write-Error ("{0} exited with code {1}:`n{2}" -f $Label, $LASTEXITCODE, ($out -join "`n"))
|
||||||
throw ("{0} failed ({1})" -f $Label, $LASTEXITCODE)
|
throw ("{0} exited with code {1}" -f $Label, $LASTEXITCODE)
|
||||||
}
|
}
|
||||||
return $out
|
return $out
|
||||||
}
|
}
|
||||||
@@ -26,21 +91,23 @@ function Invoke-Scp {
|
|||||||
if ($Recurse) { $arguments += '-r' }
|
if ($Recurse) { $arguments += '-r' }
|
||||||
if ($HostKey) { $arguments += @('-batch', '-hostkey', $HostKey) }
|
if ($HostKey) { $arguments += @('-batch', '-hostkey', $HostKey) }
|
||||||
if ($Pass) { $arguments += @('-pw', $Pass) }
|
if ($Pass) { $arguments += @('-pw', $Pass) }
|
||||||
$arguments += @($Local, $Dest)
|
$arguments += @($Local)
|
||||||
|
$arguments += $Dest
|
||||||
Invoke-Ext -Exe $pscp.Path -Arguments $arguments -Label 'scp'
|
Invoke-Ext -Exe $pscp.Path -Arguments $arguments -Label 'scp'
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$arguments = @('-O')
|
$arguments = @('-O')
|
||||||
if ($Quiet) { $arguments += '-q' }
|
if ($Quiet) { $arguments += '-q' }
|
||||||
if ($Recurse) { $arguments += '-r' }
|
if ($Recurse) { $arguments += '-r' }
|
||||||
$arguments += @($Local, $Dest)
|
$arguments += @($Local)
|
||||||
|
$arguments += $Dest
|
||||||
Invoke-Ext -Exe 'scp' -Arguments $arguments -Label 'scp'
|
Invoke-Ext -Exe 'scp' -Arguments $arguments -Label 'scp'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-Ssh {
|
function Invoke-Ssh {
|
||||||
param($Cmd, $User, $Remote, $Pass, [switch]$Quiet)
|
param($Cmd, $User, $Remote, $Pass, [switch]$Quiet)
|
||||||
$Cmd = $Cmd -replace "`r`n", "`n" -replace "`r", ""
|
$Cmd = Normalize-LineEndings $Cmd
|
||||||
if ($plink = Get-Command plink.exe -ErrorAction SilentlyContinue) {
|
if ($plink = Get-Command plink.exe -ErrorAction SilentlyContinue) {
|
||||||
$arguments = @('-batch', '-ssh')
|
$arguments = @('-batch', '-ssh')
|
||||||
if ($Pass) { $arguments += @('-pw', $Pass) }
|
if ($Pass) { $arguments += @('-pw', $Pass) }
|
||||||
|
|||||||
+214
-40
@@ -3,61 +3,235 @@
|
|||||||
|
|
||||||
param(
|
param(
|
||||||
[string]$Package = "lda-ipjs",
|
[string]$Package = "lda-ipjs",
|
||||||
[string]$TestName = "",
|
[switch]$AllPackages,
|
||||||
|
[string]$BinaryFilter = "",
|
||||||
|
[string]$Filter = "",
|
||||||
|
[switch]$Exact,
|
||||||
|
[switch]$List,
|
||||||
[ValidateSet("debug", "release")]
|
[ValidateSet("debug", "release")]
|
||||||
[string]$BuildProfile = "debug",
|
[string]$BuildProfile = "release",
|
||||||
[string]$password,
|
[string]$password,
|
||||||
[switch]$Verbose,
|
[switch]$Verbose,
|
||||||
[string]$RemoteTestPath = "/root/.bin/test",
|
[string]$RemoteTestPath = "/tmp/tmp/wakey-test",
|
||||||
[string]$RemoteHost = "[email protected]"
|
[string]$RemoteHost = "[email protected]",
|
||||||
|
[switch]$Ignored,
|
||||||
|
[switch]$IncludeIgnored,
|
||||||
|
[switch]$NoCapture,
|
||||||
|
[switch]$ShowOutput,
|
||||||
|
[int]$Threads = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
. "$PSScriptRoot/lib.ps1"
|
. "$PSScriptRoot/lib.ps1"
|
||||||
|
|
||||||
# Build tests and capture output
|
$password = Get-DefaultPassword $password
|
||||||
Write-Host "Building tests for $Package..." -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# Stream cargo output anc convert to text
|
function Get-WorkspacePackages {
|
||||||
$cargoOutput = cargo test --no-run -p $Package --target armv7-unknown-linux-musleabihf $(if ($BuildProfile -eq "release") { "-r" }) 2>&1 |
|
$metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json
|
||||||
ForEach-Object {
|
$workspaceMembers = [System.Collections.Generic.HashSet[string]]::new()
|
||||||
$line = if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { $_ }
|
foreach ($member in $metadata.workspace_members) {
|
||||||
if ($Verbose) {
|
[void]$workspaceMembers.Add($member)
|
||||||
Write-Host $line
|
|
||||||
}
|
}
|
||||||
$line # Pass through to capture
|
|
||||||
|
@(
|
||||||
|
$metadata.packages |
|
||||||
|
Where-Object { $workspaceMembers.Contains($_.id) } |
|
||||||
|
Select-Object -ExpandProperty name
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse test binary paths from cargo output
|
function Get-TestBinaryPaths {
|
||||||
$testBinaries = $cargoOutput |
|
param([string[]]$CargoOutput)
|
||||||
Select-String -Pattern "Executable.*\((.+)\)" |
|
|
||||||
ForEach-Object { $_.Matches.Groups[1].Value } |
|
|
||||||
Get-Item
|
|
||||||
|
|
||||||
if ($testBinaries.Count -eq 0) {
|
$paths = New-Object System.Collections.Generic.List[string]
|
||||||
Write-Error "No test binaries found! Exiting..."
|
|
||||||
# Write-Host "Cargo output:" -ForegroundColor Yellow
|
foreach ($line in $CargoOutput) {
|
||||||
# $cargoOutput | ForEach-Object { Write-Host $_ }
|
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$msg = $line | ConvertFrom-Json -ErrorAction Stop
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($msg.reason -ne "compiler-artifact") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $msg.executable) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$isTestProfile = $false
|
||||||
|
if ($null -ne $msg.profile -and $null -ne $msg.profile.test) {
|
||||||
|
$isTestProfile = [bool]$msg.profile.test
|
||||||
|
}
|
||||||
|
if (-not $isTestProfile) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths.Add([string]$msg.executable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@($paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-RemoteExecCommand {
|
||||||
|
param(
|
||||||
|
[string]$RemotePath,
|
||||||
|
[string[]]$Arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
$quotedPath = Quote-ShArg $RemotePath
|
||||||
|
$quotedArgs = @($Arguments | ForEach-Object { Quote-ShArg "$_" })
|
||||||
|
$exec = (@($quotedPath) + $quotedArgs) -join " "
|
||||||
|
"chmod +x $quotedPath && $exec"
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-RemoteRunDir {
|
||||||
|
param(
|
||||||
|
[string]$BasePath,
|
||||||
|
[string]$PackageName
|
||||||
|
)
|
||||||
|
|
||||||
|
$baseNorm = Normalize-PosixPath $BasePath
|
||||||
|
$leaf = [IO.Path]::GetFileName($baseNorm)
|
||||||
|
$parent = Normalize-PosixPath ([IO.Path]::GetDirectoryName($baseNorm))
|
||||||
|
if ([string]::IsNullOrWhiteSpace($parent)) {
|
||||||
|
$parent = "/tmp"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($leaf)) {
|
||||||
|
$leaf = "wakey-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
$random = [System.Guid]::NewGuid().ToString("N").Substring(0, 10)
|
||||||
|
$safePackage = ($PackageName -replace '[^A-Za-z0-9._-]', '_')
|
||||||
|
return (Join-PosixPath $parent "$leaf-$safePackage-$random")
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-RemoteBinaryPath {
|
||||||
|
param(
|
||||||
|
[string]$RemoteRunDir,
|
||||||
|
[System.IO.FileInfo]$TestBinary
|
||||||
|
)
|
||||||
|
|
||||||
|
$safeName = ($TestBinary.Name -replace '[^A-Za-z0-9._-]', '_')
|
||||||
|
Join-PosixPath $RemoteRunDir $safeName
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-RemoteParentDir {
|
||||||
|
param(
|
||||||
|
[string]$RemoteDirPath,
|
||||||
|
[string]$RemoteHost,
|
||||||
|
[string]$Password
|
||||||
|
)
|
||||||
|
|
||||||
|
$dir = Normalize-PosixPath $RemoteDirPath
|
||||||
|
if ([string]::IsNullOrWhiteSpace($dir)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Invoke-Ssh -Cmd ("mkdir -p " + (Quote-ShArg $dir)) -Remote $RemoteHost -Pass $Password -Quiet
|
||||||
|
}
|
||||||
|
|
||||||
|
$packages = if ($AllPackages) { Get-WorkspacePackages } else { @($Package) }
|
||||||
|
$failures = New-Object System.Collections.Generic.List[string]
|
||||||
|
|
||||||
|
foreach ($packageName in $packages) {
|
||||||
|
Write-Host "Building tests for $packageName..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Stream cargo output and convert to text
|
||||||
|
$cargoOutput = cargo test --no-run -p $packageName --target armv7-unknown-linux-musleabihf --message-format json $(if ($BuildProfile -eq "release") { "-r" }) 2>&1 |
|
||||||
|
ForEach-Object {
|
||||||
|
$line = if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { $_ }
|
||||||
|
# if ($Verbose) {
|
||||||
|
# Write-Host $line # this thing fills QUICK im not printing any
|
||||||
|
# }
|
||||||
|
$line
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
$failures.Add("$packageName (build failed)")
|
||||||
|
Write-Warning "Build failed for $packageName"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$testBinaryPaths = Get-TestBinaryPaths $cargoOutput
|
||||||
|
$testBinaries = @($testBinaryPaths | Get-Item)
|
||||||
|
|
||||||
|
if ($BinaryFilter) {
|
||||||
|
$testBinaries = @(
|
||||||
|
$testBinaries |
|
||||||
|
Where-Object {
|
||||||
|
$_.Name -like "*$BinaryFilter*" -or $_.BaseName -like "*$BinaryFilter*"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($testBinaries.Count -eq 0) {
|
||||||
|
$reason = if ($BinaryFilter) {
|
||||||
|
"no test executables matched binary filter '$BinaryFilter'"
|
||||||
|
} else {
|
||||||
|
"no test executables"
|
||||||
|
}
|
||||||
|
Write-Host "Skipping ${packageName}: $reason" -ForegroundColor DarkYellow
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Found $($testBinaries.Count) test $($testBinaries.Count -eq 1 ? "binary" : "binaries") for $packageName" -ForegroundColor Green
|
||||||
|
|
||||||
|
$remoteRunDir = New-RemoteRunDir -BasePath $RemoteTestPath -PackageName $packageName
|
||||||
|
Ensure-RemoteParentDir -RemoteDirPath $remoteRunDir -RemoteHost $RemoteHost -Password $password
|
||||||
|
|
||||||
|
try {
|
||||||
|
$remoteUploadDir = (Normalize-PosixPath $remoteRunDir).TrimEnd('/') + '/'
|
||||||
|
Invoke-Scp -Local ($testBinaries | ForEach-Object { $_.FullName }) -Dest "${RemoteHost}:$remoteUploadDir" -Pass $password -Quiet
|
||||||
|
|
||||||
|
foreach ($testBinary in $testBinaries) {
|
||||||
|
Write-Host "`nTesting: $packageName / $($testBinary.Name)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$remoteBinaryPath = New-RemoteBinaryPath -RemoteRunDir $remoteRunDir -TestBinary $testBinary
|
||||||
|
|
||||||
|
# Build test args
|
||||||
|
$parts = @()
|
||||||
|
if ($Filter) { $parts += $Filter }
|
||||||
|
if ($Exact) { $parts += "--exact" }
|
||||||
|
if ($List) { $parts += "--list" }
|
||||||
|
if ($Ignored) { $parts += "--ignored" }
|
||||||
|
if ($IncludeIgnored) { $parts += "--include-ignored" }
|
||||||
|
if ($ShowOutput -or $Verbose) { $parts += "--show-output" }
|
||||||
|
if ($NoCapture -or $Verbose) { $parts += "--nocapture" }
|
||||||
|
if ($Threads -gt 0) { $parts += "--test-threads"; $parts += $Threads }
|
||||||
|
$remoteCmd = Build-RemoteExecCommand -RemotePath $remoteBinaryPath -Arguments $parts
|
||||||
|
|
||||||
|
# Run test binary (with chmod to ensure executable)
|
||||||
|
try {
|
||||||
|
Invoke-Ssh -Cmd $remoteCmd -Remote $RemoteHost -Pass $password
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$failures.Add("$packageName / $($testBinary.Name)")
|
||||||
|
Write-Warning "Test binary failed: $packageName / $($testBinary.Name)"
|
||||||
|
Write-Warning $_.Exception.Message
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
try {
|
||||||
|
Invoke-Ssh -Cmd ("rm -rf " + (Quote-ShArg $remoteRunDir)) -Remote $RemoteHost -Pass $password -Quiet
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Failed to remove remote test dir: $remoteRunDir"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failures.Count -gt 0) {
|
||||||
|
Write-Error ("One or more test binaries failed: {0}" -f ($failures -join ", "))
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Found $($testBinaries.Count) test $($testBinaries.Count -eq 1 ? "binary" : "binaries")" -ForegroundColor Green
|
|
||||||
|
|
||||||
# Run each test binary
|
|
||||||
foreach ($testBinary in $testBinaries) {
|
|
||||||
Write-Host "`nTesting: $($testBinary.Name)" -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# Copy to target
|
|
||||||
Invoke-Scp -Local $testBinary.FullName -Dest "${RemoteHost}:$RemoteTestPath" -Pass $password -Quiet
|
|
||||||
|
|
||||||
# Run on target
|
|
||||||
$testArgs = "$(if ($Verbose) {"--nocapture --show-output"})"
|
|
||||||
if ($TestName) {
|
|
||||||
$testArgs = "$TestName $testArgs"
|
|
||||||
}
|
|
||||||
|
|
||||||
Invoke-Ssh -Cmd "chmod +x $RemoteTestPath && $RemoteTestPath $testArgs" -Remote $RemoteHost -Pass $password
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "`nDone!" -ForegroundColor Green
|
Write-Host "`nDone!" -ForegroundColor Green
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
use std::net::AddrParseError;
|
|
||||||
|
|
||||||
use strum::Display;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Display, Error)]
|
|
||||||
pub enum IPNeighParseError {
|
|
||||||
IpWhere, // i never seen a ip neigh where the first thing aint an ip
|
|
||||||
IpParseError(AddrParseError),
|
|
||||||
// DevWhere,
|
|
||||||
MacParseError(macaddr::ParseError),
|
|
||||||
StateWhere, // i never seen a ip neigh without the big FAILED at the end
|
|
||||||
StateParseError(strum::ParseError),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<AddrParseError> for IPNeighParseError {
|
|
||||||
fn from(value: AddrParseError) -> Self {
|
|
||||||
Self::IpParseError(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl From<macaddr::ParseError> for IPNeighParseError {
|
|
||||||
fn from(value: macaddr::ParseError) -> Self {
|
|
||||||
Self::MacParseError(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl From<strum::ParseError> for IPNeighParseError {
|
|
||||||
fn from(value: strum::ParseError) -> Self {
|
|
||||||
Self::StateParseError(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
//! r#impl AHHHHHH
|
|
||||||
|
|
||||||
use serde::{Deserialize, Deserializer, de};
|
|
||||||
|
|
||||||
use super::NUDState;
|
|
||||||
|
|
||||||
// Case-insensitive parsing for NUDState via manual Deserialize
|
|
||||||
impl<'de> Deserialize<'de> for NUDState {
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let s: &str = <&str as Deserialize>::deserialize(deserializer)?;
|
|
||||||
s.parse().map_err(de::Error::custom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use crate::arpparse::IpNeighLine;
|
|
||||||
|
|
||||||
use lda_ipjs::subcommands::neighbor::{self as ipjs_neigh, NeighborItem};
|
|
||||||
|
|
||||||
impl From<ipjs_neigh::NUDState> for NUDState {
|
|
||||||
fn from(value: ipjs_neigh::NUDState) -> Self {
|
|
||||||
match value {
|
|
||||||
ipjs_neigh::NUDState::Permanent => NUDState::Permanent,
|
|
||||||
ipjs_neigh::NUDState::Noarp => NUDState::Noarp,
|
|
||||||
ipjs_neigh::NUDState::Reachable => NUDState::Reachable,
|
|
||||||
ipjs_neigh::NUDState::Stale => NUDState::Stale,
|
|
||||||
ipjs_neigh::NUDState::None => NUDState::None,
|
|
||||||
ipjs_neigh::NUDState::Incomplete => NUDState::Incomplete,
|
|
||||||
ipjs_neigh::NUDState::Delay => NUDState::Delay,
|
|
||||||
ipjs_neigh::NUDState::Probe => NUDState::Probe,
|
|
||||||
ipjs_neigh::NUDState::Failed => NUDState::Failed,
|
|
||||||
ipjs_neigh::NUDState::Other(_) => NUDState::None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<NUDState> for ipjs_neigh::NUDState {
|
|
||||||
fn from(value: NUDState) -> Self {
|
|
||||||
match value {
|
|
||||||
NUDState::Permanent => ipjs_neigh::NUDState::Permanent,
|
|
||||||
NUDState::Noarp => ipjs_neigh::NUDState::Noarp,
|
|
||||||
NUDState::Reachable => ipjs_neigh::NUDState::Reachable,
|
|
||||||
NUDState::Stale => ipjs_neigh::NUDState::Stale,
|
|
||||||
NUDState::None => ipjs_neigh::NUDState::None,
|
|
||||||
NUDState::Incomplete => ipjs_neigh::NUDState::Incomplete,
|
|
||||||
NUDState::Delay => ipjs_neigh::NUDState::Delay,
|
|
||||||
NUDState::Probe => ipjs_neigh::NUDState::Probe,
|
|
||||||
NUDState::Failed => ipjs_neigh::NUDState::Failed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<NeighborItem> for IpNeighLine {
|
|
||||||
fn from(
|
|
||||||
NeighborItem {
|
|
||||||
ip,
|
|
||||||
dev,
|
|
||||||
mac,
|
|
||||||
state,
|
|
||||||
}: NeighborItem,
|
|
||||||
) -> Self {
|
|
||||||
IpNeighLine {
|
|
||||||
ip,
|
|
||||||
dev,
|
|
||||||
mac,
|
|
||||||
state: state
|
|
||||||
.into_iter()
|
|
||||||
.map(Into::into)
|
|
||||||
.max()
|
|
||||||
.unwrap_or(NUDState::None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
// struct arp;
|
|
||||||
|
|
||||||
// async fn read_arp() -> io::Result<()> {
|
|
||||||
// let arp_file = tokio::fs::File::open("/proc/net/arp").await?;
|
|
||||||
// let arp_read = BufReader::new(arp_file);
|
|
||||||
// Ok(())
|
|
||||||
// }
|
|
||||||
|
|
||||||
//! ip neigh pass
|
|
||||||
|
|
||||||
use std::{net::IpAddr, str::FromStr};
|
|
||||||
|
|
||||||
use crate::utils::parse::mac;
|
|
||||||
use macaddr::MacAddr;
|
|
||||||
use serde_with::skip_serializing_none;
|
|
||||||
use strum::{Display, EnumString};
|
|
||||||
|
|
||||||
use crate::arpparse::error::IPNeighParseError;
|
|
||||||
mod error;
|
|
||||||
mod impls; // custom (de)serialization impls
|
|
||||||
|
|
||||||
/// ip neigh has some cool shit.
|
|
||||||
///
|
|
||||||
/// IP
|
|
||||||
/// dev DEV | None
|
|
||||||
/// lladdr MAC | None
|
|
||||||
/// status { permanent | noarp | stale | reachable | none | incomplete | delay | probe | failed } (ip neigh help)
|
|
||||||
///
|
|
||||||
/// so you can see its damn good
|
|
||||||
#[skip_serializing_none]
|
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, serde::Serialize)]
|
|
||||||
pub struct IpNeighLine {
|
|
||||||
pub ip: IpAddr,
|
|
||||||
pub dev: Option<String>,
|
|
||||||
/// link layer address
|
|
||||||
#[serde(with = "mac::option_mac")]
|
|
||||||
pub mac: Option<MacAddr>,
|
|
||||||
/// Neighbour Unreachability Detection
|
|
||||||
pub state: NUDState,
|
|
||||||
}
|
|
||||||
|
|
||||||
// NUDState custom Deserialize now lives in arpparse/impl.rs; use serde_with OneOrMany for Vec
|
|
||||||
|
|
||||||
#[derive(
|
|
||||||
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, serde::Serialize, Default,
|
|
||||||
)]
|
|
||||||
#[strum(serialize_all = "UPPERCASE", ascii_case_insensitive)]
|
|
||||||
#[serde(rename_all = "UPPERCASE")]
|
|
||||||
pub enum NUDState {
|
|
||||||
/// the neighbour entry is valid forever and can
|
|
||||||
/// be only be removed administratively.
|
|
||||||
Permanent,
|
|
||||||
|
|
||||||
/// the neighbour entry is valid. No attempts to
|
|
||||||
/// validate this entry will be made but it can
|
|
||||||
/// be removed when its lifetime expires.
|
|
||||||
Noarp,
|
|
||||||
|
|
||||||
/// the neighbour entry is valid until the
|
|
||||||
/// reachability timeout expires.
|
|
||||||
Reachable,
|
|
||||||
|
|
||||||
/// the neighbour entry is valid but suspicious.
|
|
||||||
/// This option to ip neigh does not change the
|
|
||||||
/// neighbour state if it was valid and the
|
|
||||||
/// address is not changed by this command.
|
|
||||||
Stale,
|
|
||||||
|
|
||||||
/// the neighbour entry has not (yet) been
|
|
||||||
/// validated/resolved.
|
|
||||||
Incomplete,
|
|
||||||
/// neighbor entry validation is currently
|
|
||||||
/// delayed.
|
|
||||||
Delay,
|
|
||||||
/// neighbor is being probed.
|
|
||||||
Probe,
|
|
||||||
/// max number of probes exceeded without
|
|
||||||
/// success, neighbor validation has ultimately
|
|
||||||
/// failed.
|
|
||||||
Failed,
|
|
||||||
|
|
||||||
/// this is a pseudo state used when initially
|
|
||||||
/// creating a neighbour entry or after trying to
|
|
||||||
/// remove it before it becomes free to do so.
|
|
||||||
#[serde(other)]
|
|
||||||
#[default]
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NUDState {
|
|
||||||
/// Argument form expected by `ip neigh ... nud <state>` (lowercase)
|
|
||||||
pub const fn as_ip_neigh_arg(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
NUDState::Permanent => "permanent",
|
|
||||||
NUDState::Reachable => "reachable",
|
|
||||||
NUDState::Stale => "stale",
|
|
||||||
NUDState::Delay => "delay",
|
|
||||||
NUDState::Probe => "probe",
|
|
||||||
NUDState::Incomplete => "incomplete",
|
|
||||||
NUDState::Noarp => "noarp",
|
|
||||||
NUDState::None => "none",
|
|
||||||
NUDState::Failed => "failed",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// dumb UI label
|
|
||||||
pub fn _dumber_state(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
NUDState::Permanent | NUDState::Reachable => "online",
|
|
||||||
NUDState::Stale => "maybe online",
|
|
||||||
NUDState::Delay | NUDState::Probe | NUDState::Incomplete => "resolving",
|
|
||||||
NUDState::Noarp => "static",
|
|
||||||
NUDState::None => "unknown",
|
|
||||||
NUDState::Failed => "offline",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// dumb boolean: Some(true)=on, Some(false)=off, None=shrug
|
|
||||||
pub fn _dumber_state_this_way(&self) -> Option<bool> {
|
|
||||||
match self {
|
|
||||||
NUDState::Permanent | NUDState::Reachable => Some(true),
|
|
||||||
NUDState::Failed => Some(false),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// thanks copilot for the PEAK
|
|
||||||
pub fn parse_ip_neigh_line(s: &str) -> Result<IpNeighLine, IPNeighParseError> {
|
|
||||||
let mut it = s.split_whitespace();
|
|
||||||
let ip: IpAddr = it.next().ok_or(IPNeighParseError::IpWhere)?.parse()?;
|
|
||||||
|
|
||||||
let mut dev: Option<String> = None;
|
|
||||||
let mut mac: Option<MacAddr> = None;
|
|
||||||
let mut state: Option<NUDState> = None;
|
|
||||||
let mut last_tok: Option<&str> = None;
|
|
||||||
|
|
||||||
while let Some(tok) = it.next() {
|
|
||||||
match tok {
|
|
||||||
"dev" => dev = it.next().map(str::to_string),
|
|
||||||
"lladdr" => {
|
|
||||||
mac = it.next().map(|m| m.parse()).transpose()?;
|
|
||||||
}
|
|
||||||
"nud" => {
|
|
||||||
// we know this aint happening
|
|
||||||
state = it.next().map(|st| st.parse()).transpose()?;
|
|
||||||
}
|
|
||||||
other => last_tok = Some(other),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no explicit "nud", many outputs end with STATE
|
|
||||||
if state.is_none()
|
|
||||||
&& let Some(st) = last_tok
|
|
||||||
{
|
|
||||||
state = Some(st.parse()?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(IpNeighLine {
|
|
||||||
ip,
|
|
||||||
dev,
|
|
||||||
mac,
|
|
||||||
state: state.ok_or(IPNeighParseError::StateWhere)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for IpNeighLine {
|
|
||||||
type Err = IPNeighParseError;
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
parse_ip_neigh_line(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// pls dont touch ts
|
|
||||||
impl IpNeighLine {
|
|
||||||
/* // these are some not needed fns
|
|
||||||
pub fn set_ip(&mut self, ip: IpAddr) {
|
|
||||||
self.ip = ip;
|
|
||||||
}
|
|
||||||
pub fn set_state(&mut self, state: NUDState) {
|
|
||||||
self.state = state;
|
|
||||||
}
|
|
||||||
pub fn ip(self, ip: IpAddr) -> Self {
|
|
||||||
Self { ip, ..self }
|
|
||||||
}
|
|
||||||
pub fn state(self, state: NUDState) -> Self {
|
|
||||||
Self { state, ..self }
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
pub fn _with_dev(dev: impl Into<String>) -> impl FnMut(Self) -> Self {
|
|
||||||
let dev = dev.into();
|
|
||||||
move |self_| Self {
|
|
||||||
dev: Some(dev.clone()),
|
|
||||||
..self_
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn _with_mac(mac: MacAddr) -> impl FnMut(Self) -> Self {
|
|
||||||
move |self_| Self {
|
|
||||||
mac: Some(mac),
|
|
||||||
..self_
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ideas from copilot:
|
|
||||||
|
|
||||||
impl NUDState {
|
|
||||||
// higher is "better"/more online
|
|
||||||
pub const fn rank(self) -> u8 {
|
|
||||||
match self {
|
|
||||||
NUDState::Permanent | NUDState::Reachable => 5,
|
|
||||||
NUDState::Stale => 4,
|
|
||||||
NUDState::Delay | NUDState::Probe | NUDState::Incomplete => 3,
|
|
||||||
NUDState::Noarp => 2,
|
|
||||||
NUDState::None => 1,
|
|
||||||
NUDState::Failed => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl PartialOrd for NUDState {
|
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
||||||
Some(self.cmp(other))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Ord for NUDState {
|
|
||||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
|
||||||
self.rank().cmp(&other.rank())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IpNeighLine {
|
|
||||||
// score for “local and online”: state, has-mac, v4, iface preference
|
|
||||||
pub fn _score(&self) -> (u8, u8, u8, u8) {
|
|
||||||
let iface = self
|
|
||||||
.dev
|
|
||||||
.as_deref()
|
|
||||||
.map(|d| {
|
|
||||||
if d.starts_with("br") || d.starts_with("lan") || d.starts_with("eth") {
|
|
||||||
2
|
|
||||||
} else if d.starts_with("wlan") || d.starts_with("wl") {
|
|
||||||
1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
(
|
|
||||||
self.state.rank(),
|
|
||||||
self.mac.is_some() as u8,
|
|
||||||
matches!(self.ip, IpAddr::V4(_)) as u8,
|
|
||||||
iface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
use chrono::{DateTime, Local, Utc};
|
||||||
|
use comfy_table::{Cell, ContentArrangement, Table, presets};
|
||||||
|
use wakey_core::{DhcpLeaseWithState, InterfaceSummary, WakeResult};
|
||||||
|
|
||||||
|
fn base_table() -> Table {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(presets::UTF8_FULL_CONDENSED)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic);
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_status_table(status: &wakey::StatusResponse) -> Table {
|
||||||
|
let mut table = base_table();
|
||||||
|
table.set_header(["IP", "MAC", "State", "IF"]);
|
||||||
|
for row in &status.table {
|
||||||
|
table.add_row([
|
||||||
|
Cell::new(row.ip),
|
||||||
|
Cell::new(row.mac.map(|m| m.to_string()).unwrap_or_default()),
|
||||||
|
Cell::new(format!("{:?}", row.state).to_lowercase()),
|
||||||
|
Cell::new(row.dev.clone().unwrap_or_default()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_leases_table(leases: &[DhcpLeaseWithState]) -> Table {
|
||||||
|
let mut table = base_table();
|
||||||
|
table.set_header(["IP", "MAC", "Name", "Expires", "NUD"]);
|
||||||
|
for lease in leases {
|
||||||
|
let expires = format_epoch_local(lease.lease_line.expires_epoch);
|
||||||
|
table.add_row([
|
||||||
|
Cell::new(lease.lease_line.ip),
|
||||||
|
Cell::new(lease.lease_line.mac),
|
||||||
|
Cell::new(lease.lease_line.name.clone().unwrap_or_default()),
|
||||||
|
Cell::new(expires),
|
||||||
|
Cell::new(
|
||||||
|
lease
|
||||||
|
.nud_state
|
||||||
|
.map(|s| format!("{:?}", s).to_lowercase())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_wake_table(result: &WakeResult) -> Table {
|
||||||
|
let mut table = base_table();
|
||||||
|
table.set_header(["IP", "MAC", "Status"]);
|
||||||
|
for row in &result.result {
|
||||||
|
table.add_row([
|
||||||
|
Cell::new(row.target.ip.map(|ip| ip.to_string()).unwrap_or_default()),
|
||||||
|
Cell::new(row.target.mac.map(|m| m.to_string()).unwrap_or_default()),
|
||||||
|
Cell::new(format!("{:?}", row.status).to_lowercase()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_devs_table(devs: &[InterfaceSummary]) -> Table {
|
||||||
|
let mut table = base_table();
|
||||||
|
table.set_header([
|
||||||
|
"Interface",
|
||||||
|
"State",
|
||||||
|
"MAC",
|
||||||
|
"Family",
|
||||||
|
"Address",
|
||||||
|
"Broadcast",
|
||||||
|
"Scope/Label",
|
||||||
|
]);
|
||||||
|
|
||||||
|
for dev in devs {
|
||||||
|
if dev.addrs.is_empty() {
|
||||||
|
table.add_row([
|
||||||
|
Cell::new(&dev.ifname),
|
||||||
|
Cell::new(&dev.operstate),
|
||||||
|
Cell::new(dev.mac.map(|m| m.to_string()).unwrap_or_default()),
|
||||||
|
Cell::new(""),
|
||||||
|
Cell::new(""),
|
||||||
|
Cell::new(""),
|
||||||
|
Cell::new(""),
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (idx, addr) in dev.addrs.iter().enumerate() {
|
||||||
|
let scope_label = match (&addr.scope, &addr.label) {
|
||||||
|
(Some(scope), Some(label)) => format!("{scope} ({label})"),
|
||||||
|
(Some(scope), None) => scope.clone(),
|
||||||
|
(None, Some(label)) => label.clone(),
|
||||||
|
(None, None) => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let lead = idx == 0;
|
||||||
|
table.add_row([
|
||||||
|
Cell::new(if lead { dev.ifname.as_str() } else { "" }),
|
||||||
|
Cell::new(if lead { dev.operstate.as_str() } else { "" }),
|
||||||
|
Cell::new(if lead {
|
||||||
|
dev.mac.map(|m| m.to_string()).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}),
|
||||||
|
Cell::new(addr.family.clone().unwrap_or_default()),
|
||||||
|
Cell::new(addr.cidr.clone().unwrap_or_default()),
|
||||||
|
Cell::new(addr.broadcast.map(|ip| ip.to_string()).unwrap_or_default()),
|
||||||
|
Cell::new(scope_label),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_epoch_local(epoch: u64) -> String {
|
||||||
|
match DateTime::<Utc>::from_timestamp(epoch as i64, 0) {
|
||||||
|
Some(dt) => dt
|
||||||
|
.with_timezone(&Local)
|
||||||
|
.format("%Y-%m-%d %H:%M:%S")
|
||||||
|
.to_string(),
|
||||||
|
None => epoch.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use wakey_core::parse::mac;
|
||||||
|
use wakey_core::{
|
||||||
|
Device, DeviceFilters, DeviceInventory, DhcpLeaseWithState, NeighborEntry, Status, WakeResult,
|
||||||
|
WakeTargetResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Legacy status row shape expected by the old `/static` frontend.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LegacyStatusRow {
|
||||||
|
pub ip: std::net::IpAddr,
|
||||||
|
pub dev: Option<String>,
|
||||||
|
#[serde(with = "mac::option_mac")]
|
||||||
|
pub mac: Option<macaddr::MacAddr>,
|
||||||
|
pub state: wakey_core::NeighborState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy status response shape expected by the old `/static` frontend.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LegacyStatusResponse {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub table: Vec<LegacyStatusRow>,
|
||||||
|
pub filters: DeviceFilters,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy DHCP lease row shape expected by the old `/static` frontend.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LegacyLeaseRow {
|
||||||
|
pub expires_epoch: u64,
|
||||||
|
pub ip: std::net::IpAddr,
|
||||||
|
#[serde(with = "mac")]
|
||||||
|
pub mac: macaddr::MacAddr,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub nud_state: Option<wakey_core::NeighborState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy wake response wrapper expected by the old `/static` frontend.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LegacyWakeResult {
|
||||||
|
pub result: Vec<LegacyWakeResultRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy per-target wake row shape.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize)]
|
||||||
|
pub struct LegacyWakeResultRow {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub target: wakey_core::WakeTarget,
|
||||||
|
pub status: wakey_core::WakeStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map legacy-style status rows into the old response shape.
|
||||||
|
pub fn legacy_status_from_domain(status: Status<NeighborEntry>) -> LegacyStatusResponse {
|
||||||
|
LegacyStatusResponse {
|
||||||
|
name: status.name,
|
||||||
|
table: status.table.into_iter().map(legacy_status_row).collect(),
|
||||||
|
filters: status.filters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project a device inventory into the legacy status response shape.
|
||||||
|
pub fn legacy_status_from_inventory(
|
||||||
|
inventory: DeviceInventory,
|
||||||
|
name: Option<String>,
|
||||||
|
filters: DeviceFilters,
|
||||||
|
) -> LegacyStatusResponse {
|
||||||
|
let table = inventory
|
||||||
|
.devices
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(legacy_status_rows_from_device)
|
||||||
|
.collect();
|
||||||
|
LegacyStatusResponse {
|
||||||
|
name,
|
||||||
|
table,
|
||||||
|
filters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert one neighbor row to the legacy status row shape.
|
||||||
|
pub fn legacy_status_row(row: NeighborEntry) -> LegacyStatusRow {
|
||||||
|
LegacyStatusRow {
|
||||||
|
ip: row.ip,
|
||||||
|
dev: row.dev,
|
||||||
|
mac: row.mac,
|
||||||
|
state: row.state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project one merged device back into legacy status rows.
|
||||||
|
pub fn legacy_status_rows_from_device(device: Device) -> Vec<LegacyStatusRow> {
|
||||||
|
if !device.neighbors.is_empty() {
|
||||||
|
return device
|
||||||
|
.neighbors
|
||||||
|
.into_iter()
|
||||||
|
.map(legacy_status_row)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallback_mac = device.macs.first().copied();
|
||||||
|
let fallback_dev = device.interfaces.first().cloned();
|
||||||
|
let fallback_state = match device.presence {
|
||||||
|
wakey_core::Presence::Online => wakey_core::NeighborState::Reachable,
|
||||||
|
wakey_core::Presence::LikelyOnline => wakey_core::NeighborState::Stale,
|
||||||
|
wakey_core::Presence::Offline => wakey_core::NeighborState::Failed,
|
||||||
|
wakey_core::Presence::Unknown => wakey_core::NeighborState::None,
|
||||||
|
};
|
||||||
|
|
||||||
|
device
|
||||||
|
.ips
|
||||||
|
.into_iter()
|
||||||
|
.map(|ip| LegacyStatusRow {
|
||||||
|
ip,
|
||||||
|
dev: fallback_dev.clone(),
|
||||||
|
mac: fallback_mac,
|
||||||
|
state: fallback_state,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert lease rows into the legacy frontend shape.
|
||||||
|
pub fn legacy_leases_from_domain(leases: Vec<DhcpLeaseWithState>) -> Vec<LegacyLeaseRow> {
|
||||||
|
leases.into_iter().map(legacy_lease_row).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert one lease row into the legacy frontend shape.
|
||||||
|
pub fn legacy_lease_row(lease: DhcpLeaseWithState) -> LegacyLeaseRow {
|
||||||
|
LegacyLeaseRow {
|
||||||
|
expires_epoch: lease.lease_line.expires_epoch,
|
||||||
|
ip: lease.lease_line.ip,
|
||||||
|
mac: lease.lease_line.mac,
|
||||||
|
name: lease.lease_line.name,
|
||||||
|
nud_state: lease.nud_state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert wake results into the legacy frontend shape.
|
||||||
|
pub fn legacy_wake_from_domain(result: WakeResult) -> LegacyWakeResult {
|
||||||
|
LegacyWakeResult {
|
||||||
|
result: result.result.into_iter().map(legacy_wake_row).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert one wake result row into the legacy frontend shape.
|
||||||
|
pub fn legacy_wake_row(row: WakeTargetResult) -> LegacyWakeResultRow {
|
||||||
|
LegacyWakeResultRow {
|
||||||
|
target: row.target,
|
||||||
|
status: row.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
use wakey_core::{DhcpLease, NeighborState, WakeStatus, WakeTarget};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_status_to_legacy_shape() {
|
||||||
|
let status = Status {
|
||||||
|
name: Some("pc".into()),
|
||||||
|
table: vec![NeighborEntry {
|
||||||
|
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
|
||||||
|
dev: Some("br-lan".into()),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||||
|
state: NeighborState::Reachable,
|
||||||
|
}],
|
||||||
|
filters: DeviceFilters::default(),
|
||||||
|
};
|
||||||
|
let legacy = legacy_status_from_domain(status);
|
||||||
|
assert_eq!(legacy.name.as_deref(), Some("pc"));
|
||||||
|
assert_eq!(legacy.table.len(), 1);
|
||||||
|
assert_eq!(legacy.table[0].state, NeighborState::Reachable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_inventory_to_legacy_status_shape() {
|
||||||
|
let inventory = DeviceInventory {
|
||||||
|
devices: vec![Device::from_parts(
|
||||||
|
vec![NeighborEntry {
|
||||||
|
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
|
||||||
|
dev: Some("br-lan".into()),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||||
|
state: NeighborState::Reachable,
|
||||||
|
}],
|
||||||
|
vec![],
|
||||||
|
)],
|
||||||
|
};
|
||||||
|
let legacy =
|
||||||
|
legacy_status_from_inventory(inventory, Some("pc".into()), DeviceFilters::default());
|
||||||
|
assert_eq!(legacy.name.as_deref(), Some("pc"));
|
||||||
|
assert_eq!(legacy.table.len(), 1);
|
||||||
|
assert_eq!(legacy.table[0].state, NeighborState::Reachable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_leases_to_legacy_shape() {
|
||||||
|
let leases = vec![DhcpLeaseWithState {
|
||||||
|
lease_line: DhcpLease {
|
||||||
|
expires_epoch: 42,
|
||||||
|
ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||||
|
mac: "aa:bb:cc:dd:ee:ff".parse().expect("mac"),
|
||||||
|
name: Some("pc".into()),
|
||||||
|
},
|
||||||
|
nud_state: Some(NeighborState::Reachable),
|
||||||
|
}];
|
||||||
|
let legacy = legacy_leases_from_domain(leases);
|
||||||
|
assert_eq!(legacy.len(), 1);
|
||||||
|
assert_eq!(legacy[0].name.as_deref(), Some("pc"));
|
||||||
|
assert_eq!(legacy[0].nud_state, Some(NeighborState::Reachable));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_wake_to_legacy_shape() {
|
||||||
|
let result = WakeResult {
|
||||||
|
result: vec![WakeTargetResult {
|
||||||
|
target: WakeTarget {
|
||||||
|
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||||
|
},
|
||||||
|
status: WakeStatus::Succeed,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let legacy = legacy_wake_from_domain(result);
|
||||||
|
assert_eq!(legacy.result.len(), 1);
|
||||||
|
assert_eq!(legacy.result[0].status, WakeStatus::Succeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
pub mod compat;
|
||||||
|
pub mod route;
|
||||||
|
|
||||||
|
use std::{io, net::SocketAddr};
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
|
/// Build the temporary HTTP app that serves the legacy API and static frontend.
|
||||||
|
pub fn http_app(static_root: std::path::PathBuf) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.nest("/api", route::api_router())
|
||||||
|
.fallback_service(axum::routing::get_service(
|
||||||
|
ServeDir::new(static_root)
|
||||||
|
.append_index_html_on_directories(true)
|
||||||
|
.precompressed_br()
|
||||||
|
.precompressed_deflate()
|
||||||
|
.precompressed_gzip()
|
||||||
|
.precompressed_zstd(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve the temporary HTTP app on the provided socket address.
|
||||||
|
pub async fn serve_http(addr: SocketAddr, static_root: std::path::PathBuf) -> io::Result<()> {
|
||||||
|
let listener = TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(listener, http_app(static_root).into_make_service()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve the HTTP app using the `static/` directory next to the current executable.
|
||||||
|
pub async fn serve_http_from_current_exe(addr: SocketAddr) -> io::Result<()> {
|
||||||
|
let exe = std::env::current_exe()?;
|
||||||
|
let root = exe
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| io::Error::other("no parent dir"))?;
|
||||||
|
serve_http(addr, root.join("static")).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
use crate::http::route::error::ApiError;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::{extract::Path, response::Redirect};
|
||||||
|
use wakey_core::DeviceQuery;
|
||||||
|
|
||||||
|
use crate::http::route::status::NamePath;
|
||||||
|
|
||||||
|
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
|
||||||
|
pub async fn status_smart_redirect(
|
||||||
|
Path(q): Path<String>,
|
||||||
|
) -> axum::response::Result<Redirect, impl IntoResponse> {
|
||||||
|
let query: DeviceQuery = match crate::service::resolve_query(q).await {
|
||||||
|
Ok(query) => query,
|
||||||
|
Err(e) => {
|
||||||
|
return Err(ApiError {
|
||||||
|
error: e.to_string(),
|
||||||
|
code: StatusCode::BAD_GATEWAY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match serde_html_form::to_string(query) {
|
||||||
|
Ok(e) => Ok(Redirect::to(&format!("/api/status?{e}"))),
|
||||||
|
Err(e) => Err(ApiError {
|
||||||
|
error: e.to_string(),
|
||||||
|
code: StatusCode::BAD_GATEWAY,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirect {
|
||||||
|
Redirect::permanent(&format!(
|
||||||
|
"/api/status?name={name}",
|
||||||
|
name = urlencoding::encode(&name) // just for
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ip(Path(name): Path<String>) -> impl IntoResponse {
|
||||||
|
crate::service::get_ips(&name).await.map_or_else(
|
||||||
|
|e| ApiError::ise(e.to_string()).into_response(),
|
||||||
|
|ips| Json(ips).into_response(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use crate::http::route::error::ApiError;
|
||||||
|
use axum::{Json, response::IntoResponse};
|
||||||
|
|
||||||
|
pub async fn devs_router() -> impl IntoResponse {
|
||||||
|
match crate::service::list_interfaces().await {
|
||||||
|
Ok(devs) => Json(devs).into_response(),
|
||||||
|
Err(e) => ApiError::ise(e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Device listing endpoints
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use crate::{http::route::error::ApiError, utils::parse::boolish_str};
|
||||||
|
use axum::{Json, extract::Query, http::StatusCode, response::IntoResponse};
|
||||||
|
|
||||||
|
// DHCP lease endpoints
|
||||||
|
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||||
|
pub struct DhcpLeasesQueryRaw {
|
||||||
|
include_state: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_dhcp_leases(
|
||||||
|
Query(DhcpLeasesQueryRaw { include_state }): Query<DhcpLeasesQueryRaw>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let include_state = include_state.as_deref().map(boolish_str).unwrap_or(false);
|
||||||
|
|
||||||
|
match crate::service::get_leases(wakey_core::LeaseQuery { include_state }).await {
|
||||||
|
Ok(leases) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(crate::http::compat::legacy_leases_from_domain(leases)),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => ApiError {
|
||||||
|
error: e.to_string(),
|
||||||
|
code: StatusCode::BAD_GATEWAY,
|
||||||
|
}
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,15 @@ use axum::{
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ApiError {
|
pub struct ApiError<T: Serialize> {
|
||||||
#[serde(skip_serializing, skip_deserializing)]
|
#[serde(skip_serializing, skip_deserializing)]
|
||||||
pub code: StatusCode,
|
pub code: StatusCode,
|
||||||
pub error: String,
|
pub error: T,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiError {
|
impl<T: Serialize> ApiError<T> {
|
||||||
/// [StatusCode::INTERNAL_SERVER_ERROR] shortcut
|
/// [StatusCode::INTERNAL_SERVER_ERROR] shortcut
|
||||||
pub const fn ise(error: String) -> Self {
|
pub const fn ise(error: T) -> Self {
|
||||||
Self {
|
Self {
|
||||||
code: StatusCode::INTERNAL_SERVER_ERROR,
|
code: StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
error,
|
error,
|
||||||
@@ -22,7 +22,7 @@ impl ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl<T: Serialize> IntoResponse for ApiError<T> {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
(self.code, Json(self)).into_response()
|
(self.code, Json(self)).into_response()
|
||||||
}
|
}
|
||||||
@@ -5,15 +5,15 @@ pub mod error;
|
|||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod wake;
|
pub mod wake;
|
||||||
|
|
||||||
// use crate::assets;
|
use crate::http::route::api::ip;
|
||||||
use crate::dhcpparse::load_mac_name_cache;
|
use crate::http::route::api::status_redirect;
|
||||||
use crate::route::api::ip;
|
use crate::http::route::api::status_smart_redirect;
|
||||||
use crate::route::api::status_redirect;
|
use crate::http::route::devs::devs_router;
|
||||||
use crate::route::api::status_smart_redirect;
|
use crate::http::route::dhcp::get_dhcp_leases;
|
||||||
use crate::route::devs::devs_router;
|
use crate::http::route::error::ApiError;
|
||||||
use crate::route::dhcp::get_dhcp_leases;
|
use crate::http::route::status::get_status_json;
|
||||||
use crate::route::status::get_status_json;
|
use crate::http::route::wake::wake_multi;
|
||||||
use crate::route::wake::wake_multi;
|
use crate::legacy::dhcpparse::load_mac_name_cache;
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
@@ -46,11 +46,9 @@ pub fn api_router() -> Router {
|
|||||||
.route("/ips/{name}", get(ip))
|
.route("/ips/{name}", get(ip))
|
||||||
.route(
|
.route(
|
||||||
"/mac-cache",
|
"/mac-cache",
|
||||||
get(|| async {
|
get(async || match load_mac_name_cache().await {
|
||||||
match load_mac_name_cache().await {
|
Ok(h) => Json(h).into_response(),
|
||||||
Ok(h) => Json(h).into_response(),
|
Err(e) => ApiError::ise(e.to_string()).into_response(),
|
||||||
Err(e) => e.to_string().into_response(),
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.layer(middleware::from_fn(add_performance_header))
|
.layer(middleware::from_fn(add_performance_header))
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
use crate::http::route::error::ApiError;
|
||||||
|
use axum::{Json, http::StatusCode, response::IntoResponse};
|
||||||
|
use axum_extra::extract::Query;
|
||||||
|
pub use wakey_core::{DeviceQuery, NamePath};
|
||||||
|
|
||||||
|
pub async fn get_status_json(Query(query): Query<DeviceQuery>) -> impl IntoResponse {
|
||||||
|
match crate::service::inventory(query.clone()).await {
|
||||||
|
Ok(inventory) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(crate::http::compat::legacy_status_from_inventory(
|
||||||
|
inventory,
|
||||||
|
query.name,
|
||||||
|
query.filter,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(error) => ApiError {
|
||||||
|
code: StatusCode::BAD_GATEWAY,
|
||||||
|
error: error
|
||||||
|
.chain()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(": "),
|
||||||
|
}
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Status endpoints
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use crate::http::route::error::ApiError;
|
||||||
|
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
|
||||||
|
pub use wakey_core::WakeTarget;
|
||||||
|
|
||||||
|
pub async fn wake_multi(Json(req): Json<Vec<WakeTarget>>) -> impl IntoResponse {
|
||||||
|
match crate::service::wake_targets(req).await {
|
||||||
|
Ok(result) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(crate::http::compat::legacy_wake_from_domain(result)),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(error) => {
|
||||||
|
let error = format!("Error: {}", error);
|
||||||
|
ApiError::ise(error).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub use wakey_core::NeighborEntry as IpNeighLine;
|
||||||
|
pub use wakey_core::NeighborState as NUDState;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub use wakey_linux::dhcp::{load_mac_name_cache, read_dhcp_leases_with_names};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod arpparse;
|
||||||
|
pub mod dhcpparse;
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
pub mod http;
|
||||||
|
pub mod legacy;
|
||||||
|
pub mod service;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
|
pub use http::{http_app, serve_http, serve_http_from_current_exe};
|
||||||
|
pub use service::{
|
||||||
|
StatusResponse, broadcast_wake_targets, device_to_status_rows, get_interface_summaries,
|
||||||
|
get_interface_summary, get_ips, get_leases, get_status, get_status_for_input, inventory,
|
||||||
|
leases_without_state, list_interfaces, merge_devices, query_to_device_query, resolve_devices,
|
||||||
|
resolve_query, resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query,
|
||||||
|
wake_targets,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
use wakey_core::{
|
||||||
|
DhcpLease, NeighborEntry, NeighborState, Presence, Query, WakeStatus, WakeTarget,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolve_query_parses_ip() {
|
||||||
|
let query = resolve_query("192.168.1.10").await.expect("resolve query");
|
||||||
|
assert_eq!(
|
||||||
|
query.filter.ips,
|
||||||
|
vec![IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10))]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolve_query_parses_mac() {
|
||||||
|
let query = resolve_query("aa:bb:cc:dd:ee:ff")
|
||||||
|
.await
|
||||||
|
.expect("resolve query");
|
||||||
|
assert_eq!(query.filter.macs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolve_query_parses_nud() {
|
||||||
|
let query = resolve_query("reachable").await.expect("resolve query");
|
||||||
|
assert_eq!(query.filter.nuds, vec![NeighborState::Reachable]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolve_selector_keeps_text_vs_structured() {
|
||||||
|
let selector = resolve_selector("reachable")
|
||||||
|
.await
|
||||||
|
.expect("resolve selector");
|
||||||
|
match selector {
|
||||||
|
Query::NeighborState(NeighborState::Reachable) => {}
|
||||||
|
_ => panic!("expected neighbor-state selector"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leases_without_state_clears_nud_state() {
|
||||||
|
let leases = vec![DhcpLease {
|
||||||
|
expires_epoch: 1,
|
||||||
|
ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||||
|
mac: "aa:bb:cc:dd:ee:ff".parse().expect("mac"),
|
||||||
|
name: Some("pc".into()),
|
||||||
|
}];
|
||||||
|
let out = leases_without_state(leases);
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert!(out[0].nud_state.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_devices_combines_lease_and_neighbor() {
|
||||||
|
let neighbors = vec![NeighborEntry {
|
||||||
|
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
|
||||||
|
dev: Some("br-lan".into()),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||||
|
state: NeighborState::Reachable,
|
||||||
|
}];
|
||||||
|
let leases = vec![wakey_core::DhcpLeaseWithState {
|
||||||
|
lease_line: DhcpLease {
|
||||||
|
expires_epoch: 1,
|
||||||
|
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
|
||||||
|
mac: "aa:bb:cc:dd:ee:ff".parse().expect("mac"),
|
||||||
|
name: Some("pc".into()),
|
||||||
|
},
|
||||||
|
nud_state: None,
|
||||||
|
}];
|
||||||
|
let devices = merge_devices(neighbors, leases, &wakey_core::DeviceQuery::default());
|
||||||
|
assert_eq!(devices.len(), 1);
|
||||||
|
assert_eq!(devices[0].presence, Presence::Online);
|
||||||
|
assert_eq!(devices[0].names, vec!["pc".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wake_targets_marks_incomplete() {
|
||||||
|
let out = wake_targets(vec![WakeTarget {
|
||||||
|
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
|
||||||
|
mac: None,
|
||||||
|
}])
|
||||||
|
.await
|
||||||
|
.expect("wake");
|
||||||
|
assert_eq!(out.result.len(), 1);
|
||||||
|
assert_eq!(out.result[0].status, WakeStatus::Incomplete);
|
||||||
|
}
|
||||||
|
}
|
||||||
+308
-52
@@ -1,65 +1,321 @@
|
|||||||
//! braindead version v0.1.x
|
mod cli_table;
|
||||||
//!
|
|
||||||
//! # whats next
|
|
||||||
//!
|
|
||||||
//! for version 2 i hope to have:
|
|
||||||
//!
|
|
||||||
//! 1. idk reworked frontend;
|
|
||||||
//! 2. incorporate ip -j;
|
|
||||||
//! 3. small 1-5 second caching;
|
|
||||||
|
|
||||||
use axum::Router;
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use tokio::net::TcpListener;
|
|
||||||
mod arpparse;
|
|
||||||
mod dhcpparse;
|
|
||||||
mod route;
|
|
||||||
mod utils;
|
|
||||||
use std::{env, io};
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
use clap::{Args, Parser, Subcommand};
|
||||||
#[tokio::main]
|
use wakey_core::{DeviceFilters, DeviceQuery, InterfaceSummary, WakeResult};
|
||||||
async fn entry() -> io::Result<()> {
|
|
||||||
use crate::route::api_router;
|
#[derive(Parser)]
|
||||||
use axum::routing::get_service;
|
#[command(name = "wakey")]
|
||||||
use tower_http::services::ServeDir;
|
#[command(version, about = "CLI and temporary HTTP adapter for Wakey")]
|
||||||
let exe = env::current_exe()?;
|
#[command(
|
||||||
let root = exe
|
long_about = "Wakey can run as a local/operator CLI or serve the legacy HTTP/static interface during the migration to a service-first architecture."
|
||||||
.parent()
|
)]
|
||||||
.ok_or_else(|| io::Error::other("no parent dir"))?;
|
struct Cli {
|
||||||
let static_dir = ServeDir::new(root.join("static"))
|
#[command(subcommand)]
|
||||||
.append_index_html_on_directories(true)
|
command: Command,
|
||||||
.precompressed_br()
|
}
|
||||||
.precompressed_deflate()
|
|
||||||
.precompressed_gzip()
|
#[derive(Subcommand)]
|
||||||
.precompressed_zstd();
|
enum Command {
|
||||||
let app = Router::new()
|
/// Serve the temporary legacy HTTP/static app.
|
||||||
// .route("/home", get(home))
|
Http(HttpArgs),
|
||||||
// .route("/", get(home_2))
|
/// Show device status rows from neighbor/device data.
|
||||||
// .merge(home_2_route())
|
Status(StatusArgs),
|
||||||
// .route("/status", get(get_status_2))
|
/// Show DHCP leases, optionally enriched with current neighbor state.
|
||||||
.nest("/api", api_router())
|
Leases(LeasesArgs),
|
||||||
.fallback_service(get_service(static_dir));
|
/// Send Wake-on-LAN packets from a query or explicit MAC/IP pair.
|
||||||
|
Wake(WakeArgs),
|
||||||
|
/// Show condensed network interface summaries.
|
||||||
|
Devs(DevsArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct HttpArgs {
|
||||||
|
/// Host address to bind the HTTP server to.
|
||||||
|
#[arg(long, default_value = "::")]
|
||||||
|
host: IpAddr,
|
||||||
|
/// TCP port to bind the HTTP server to.
|
||||||
|
#[arg(long, default_value_t = 12012)]
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct LeasesArgs {
|
||||||
|
/// Include best-known current neighbor state for each lease IP.
|
||||||
|
#[arg(long)]
|
||||||
|
include_state: bool,
|
||||||
|
/// Print machine-readable JSON instead of a table.
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
#[command(after_long_help = "Examples:
|
||||||
|
wakey wake bedroom-pc
|
||||||
|
wakey wake --mac aa:bb:cc:dd:ee:ff
|
||||||
|
wakey wake --mac aa:bb:cc:dd:ee:ff --ip 192.168.1.255
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- query mode and explicit --mac/--ip mode are mutually exclusive
|
||||||
|
- --ip requires --mac
|
||||||
|
- --mac without --ip fans out to interface broadcast targets")]
|
||||||
|
struct WakeArgs {
|
||||||
|
/// Free-form device query, for example a hostname, IP, MAC, interface, or NUD state.
|
||||||
|
query: Option<String>,
|
||||||
|
/// Explicit MAC address for manual wake mode.
|
||||||
|
#[arg(long)]
|
||||||
|
mac: Option<macaddr::MacAddr>,
|
||||||
|
/// Explicit destination IP or broadcast address for manual wake mode.
|
||||||
|
#[arg(long)]
|
||||||
|
ip: Option<IpAddr>,
|
||||||
|
/// Print machine-readable JSON instead of a table.
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
#[command(after_long_help = "Examples:
|
||||||
|
wakey status bedroom-pc
|
||||||
|
wakey status --mac aa:bb:cc:dd:ee:ff
|
||||||
|
wakey status --dev br-lan --nud reachable
|
||||||
|
|
||||||
|
If only the positional query is provided, it is treated as free-form input and resolved through the smart selector path.")]
|
||||||
|
struct StatusArgs {
|
||||||
|
/// Free-form device query.
|
||||||
|
query: Option<String>,
|
||||||
|
/// Explicit name/text filter.
|
||||||
|
#[arg(long)]
|
||||||
|
name: Option<String>,
|
||||||
|
/// Explicit IP filters.
|
||||||
|
#[arg(long = "ip")]
|
||||||
|
ips: Vec<std::net::IpAddr>,
|
||||||
|
/// Explicit interface-name filters.
|
||||||
|
#[arg(long = "dev")]
|
||||||
|
devs: Vec<String>,
|
||||||
|
/// Explicit neighbor-state filters.
|
||||||
|
#[arg(long = "nud")]
|
||||||
|
nuds: Vec<wakey_core::NeighborState>,
|
||||||
|
/// Explicit MAC-address filters.
|
||||||
|
#[arg(long = "mac")]
|
||||||
|
macs: Vec<macaddr::MacAddr>,
|
||||||
|
/// Print machine-readable JSON instead of a table.
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
#[command(after_long_help = "Examples:
|
||||||
|
wakey devs
|
||||||
|
wakey devs br-lan
|
||||||
|
wakey devs --up
|
||||||
|
wakey devs --json")]
|
||||||
|
struct DevsArgs {
|
||||||
|
/// Optional interface name to show.
|
||||||
|
dev: Option<String>,
|
||||||
|
/// Show only interfaces whose operstate is `up`.
|
||||||
|
#[arg(long)]
|
||||||
|
up: bool,
|
||||||
|
/// Print machine-readable JSON instead of a table.
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_args_to_query(args: StatusArgs) -> wakey_core::DeviceQuery {
|
||||||
|
if let Some(query) = args.query.as_ref()
|
||||||
|
&& args.name.is_none()
|
||||||
|
&& args.ips.is_empty()
|
||||||
|
&& args.devs.is_empty()
|
||||||
|
&& args.nuds.is_empty()
|
||||||
|
&& args.macs.is_empty()
|
||||||
|
{
|
||||||
|
return DeviceQuery {
|
||||||
|
name: Some(query.clone()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceQuery {
|
||||||
|
name: args.name.or(args.query),
|
||||||
|
filter: DeviceFilters {
|
||||||
|
ips: args.ips,
|
||||||
|
devs: args.devs,
|
||||||
|
nuds: args.nuds,
|
||||||
|
macs: args.macs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_wake_args(args: &WakeArgs) -> anyhow::Result<()> {
|
||||||
|
let has_query = args.query.is_some();
|
||||||
|
let has_mac = args.mac.is_some();
|
||||||
|
let has_ip = args.ip.is_some();
|
||||||
|
|
||||||
|
if has_ip && !has_mac {
|
||||||
|
anyhow::bail!("`wakey wake --ip` needs `--mac`");
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_query && (has_mac || has_ip) {
|
||||||
|
anyhow::bail!("query mode and explicit `--mac/--ip` mode are mutually exclusive");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has_query && !has_mac {
|
||||||
|
anyhow::bail!("provide either a query or `--mac`");
|
||||||
|
}
|
||||||
|
|
||||||
let port = TcpListener::bind("0.0.0.0:12012").await?;
|
|
||||||
axum::serve(port, app.into_make_service()).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_wake(args: WakeArgs) -> anyhow::Result<WakeResult> {
|
||||||
|
validate_wake_args(&args)?;
|
||||||
|
|
||||||
|
match (args.query, args.mac, args.ip) {
|
||||||
|
(Some(query), None, None) => wakey::wake_from_query(query).await,
|
||||||
|
(None, Some(mac), ip) => wakey::wake_explicit(mac, ip).await,
|
||||||
|
_ => unreachable!("wake args validated before dispatch"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_interface_summaries(
|
||||||
|
mut devs: Vec<InterfaceSummary>,
|
||||||
|
args: &DevsArgs,
|
||||||
|
) -> Vec<InterfaceSummary> {
|
||||||
|
if args.up {
|
||||||
|
devs.retain(|dev| dev.operstate == "up");
|
||||||
|
}
|
||||||
|
if let Some(name) = &args.dev {
|
||||||
|
devs.retain(|dev| &dev.ifname == name);
|
||||||
|
}
|
||||||
|
devs
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
fn main() -> color_eyre::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
use std::net::ToSocketAddrs;
|
Err(anyhow::anyhow!(
|
||||||
color_eyre::install()?;
|
|
||||||
// use crate::arpparse::NUDState;
|
|
||||||
// println!("{}", NUDState::Reachable.to_string().to_lowercase());
|
|
||||||
println!("{:?}", "svuhuvshdv:331".to_socket_addrs());
|
|
||||||
// Err(Os { code: 11001, kind: Uncategorized, message: "No such host is known." })
|
|
||||||
Err(color_eyre::eyre::eyre!(
|
|
||||||
"OS not supported! run this on your ahh router!"
|
"OS not supported! run this on your ahh router!"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn main() -> color_eyre::Result<()> {
|
#[tokio::main]
|
||||||
color_eyre::install()?;
|
async fn main() -> anyhow::Result<()> {
|
||||||
Ok(entry()?)
|
let cli = Cli::parse();
|
||||||
|
match cli.command {
|
||||||
|
Command::Http(args) => {
|
||||||
|
let addr = SocketAddr::new(args.host, args.port);
|
||||||
|
wakey::serve_http_from_current_exe(addr).await?;
|
||||||
|
}
|
||||||
|
Command::Status(args) => {
|
||||||
|
let as_json = args.json;
|
||||||
|
let query = status_args_to_query(args);
|
||||||
|
let status = if query.name.is_some()
|
||||||
|
&& query.filter.ips.is_empty()
|
||||||
|
&& query.filter.devs.is_empty()
|
||||||
|
&& query.filter.nuds.is_empty()
|
||||||
|
&& query.filter.macs.is_empty()
|
||||||
|
{
|
||||||
|
wakey::get_status_for_input(query.name.clone().unwrap_or_default()).await?
|
||||||
|
} else {
|
||||||
|
wakey::get_status(query).await?
|
||||||
|
};
|
||||||
|
if as_json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&status)?);
|
||||||
|
} else {
|
||||||
|
if let Some(name) = &status.name {
|
||||||
|
println!("name: {name}");
|
||||||
|
}
|
||||||
|
println!("{}", cli_table::render_status_table(&status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::Leases(args) => {
|
||||||
|
let leases = wakey::get_leases(wakey_core::LeaseQuery {
|
||||||
|
include_state: args.include_state,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if args.json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&leases)?);
|
||||||
|
} else {
|
||||||
|
println!("{}", cli_table::render_leases_table(&leases));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::Wake(args) => {
|
||||||
|
let as_json = args.json;
|
||||||
|
let result = run_wake(args).await?;
|
||||||
|
if as_json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||||
|
} else {
|
||||||
|
println!("{}", cli_table::render_wake_table(&result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::Devs(args) => {
|
||||||
|
let devs = if let Some(name) = &args.dev {
|
||||||
|
wakey::get_interface_summary(name)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
wakey::get_interface_summaries().await?
|
||||||
|
};
|
||||||
|
let devs = filter_interface_summaries(devs, &args);
|
||||||
|
if args.json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&devs)?);
|
||||||
|
} else {
|
||||||
|
println!("{}", cli_table::render_devs_table(&devs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::WakeArgs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wake_rejects_ip_without_mac() {
|
||||||
|
let err = super::validate_wake_args(&WakeArgs {
|
||||||
|
query: None,
|
||||||
|
mac: None,
|
||||||
|
ip: Some("192.168.1.10".parse().expect("ip")),
|
||||||
|
json: false,
|
||||||
|
})
|
||||||
|
.expect_err("ip-only wake should be rejected");
|
||||||
|
|
||||||
|
assert!(err.to_string().contains("--ip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wake_rejects_mixed_query_and_explicit_mode() {
|
||||||
|
let err = super::validate_wake_args(&WakeArgs {
|
||||||
|
query: Some("pc".into()),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||||
|
ip: None,
|
||||||
|
json: false,
|
||||||
|
})
|
||||||
|
.expect_err("mixed wake mode should be rejected");
|
||||||
|
|
||||||
|
assert!(err.to_string().contains("mutually exclusive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wake_accepts_query_mode() {
|
||||||
|
super::validate_wake_args(&WakeArgs {
|
||||||
|
query: Some("pc".into()),
|
||||||
|
mac: None,
|
||||||
|
ip: None,
|
||||||
|
json: false,
|
||||||
|
})
|
||||||
|
.expect("query mode should be accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wake_accepts_manual_mac_mode() {
|
||||||
|
super::validate_wake_args(&WakeArgs {
|
||||||
|
query: None,
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||||
|
ip: None,
|
||||||
|
json: false,
|
||||||
|
})
|
||||||
|
.expect("manual mac mode should be accepted");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
use crate::route::error::ApiError;
|
|
||||||
use crate::utils::query::parser::{QueryType, parse_query};
|
|
||||||
use axum::Json;
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::IntoResponse;
|
|
||||||
use axum::{extract::Path, response::Redirect};
|
|
||||||
|
|
||||||
use crate::route::status::{DeviceQuery, Filters, NamePath};
|
|
||||||
use crate::utils::query::get_ips;
|
|
||||||
|
|
||||||
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
|
|
||||||
pub async fn status_smart_redirect(
|
|
||||||
Path(q): Path<String>,
|
|
||||||
) -> axum::response::Result<Redirect, impl IntoResponse> {
|
|
||||||
// no less bullshit
|
|
||||||
let query = match parse_query(q).await {
|
|
||||||
QueryType::Ip(ip_addr) => DeviceQuery {
|
|
||||||
filter: Filters {
|
|
||||||
ips: vec![ip_addr],
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
QueryType::Mac(mac_addr) => DeviceQuery {
|
|
||||||
filter: Filters {
|
|
||||||
macs: vec![mac_addr],
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
QueryType::Dev(s) => DeviceQuery {
|
|
||||||
filter: Filters {
|
|
||||||
devs: vec![s],
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
QueryType::Nud(nudstate) => DeviceQuery {
|
|
||||||
filter: Filters {
|
|
||||||
nuds: vec![nudstate],
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
QueryType::Unknown(n) => DeviceQuery {
|
|
||||||
name: Some(n),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
};
|
|
||||||
match serde_html_form::to_string(query) {
|
|
||||||
Ok(e) => Ok(Redirect::to(&format!("/api/status?{e}"))),
|
|
||||||
Err(e) => Err(ApiError {
|
|
||||||
error: e.to_string(),
|
|
||||||
code: StatusCode::BAD_GATEWAY,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirect {
|
|
||||||
Redirect::permanent(&format!(
|
|
||||||
"/api/status?name={name}",
|
|
||||||
name = urlencoding::encode(&name) // just for
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ip(Path(name): Path<String>) -> impl IntoResponse {
|
|
||||||
get_ips(&name).await.map_or_else(
|
|
||||||
|e| ApiError::ise(e.to_string()).into_response(),
|
|
||||||
|ips| Json(ips.collect::<Vec<_>>()).into_response(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
use crate::utils::query::dev;
|
|
||||||
use axum::Json;
|
|
||||||
|
|
||||||
pub async fn devs_router() -> Json<Vec<String>> {
|
|
||||||
dev::devs_sorted().await.into()
|
|
||||||
}
|
|
||||||
// Device listing endpoints
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
dhcpparse::read_dhcp_leases_with_names,
|
|
||||||
route::error::ApiError,
|
|
||||||
utils::{parse::boolish_str, query::enrich_leases_with_nud_state},
|
|
||||||
};
|
|
||||||
use axum::{Json, extract::Query, http::StatusCode, response::IntoResponse};
|
|
||||||
|
|
||||||
// DHCP lease endpoints
|
|
||||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
|
||||||
pub struct DhcpLeasesQueryRaw {
|
|
||||||
include_state: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_dhcp_leases(Query(raw): Query<DhcpLeasesQueryRaw>) -> impl IntoResponse {
|
|
||||||
let include_state = raw
|
|
||||||
.include_state
|
|
||||||
.as_deref()
|
|
||||||
.map(boolish_str)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
match read_dhcp_leases_with_names().await {
|
|
||||||
Ok(leases_with_names) => {
|
|
||||||
if !include_state {
|
|
||||||
return (StatusCode::OK, Json(leases_with_names)).into_response();
|
|
||||||
}
|
|
||||||
let out = enrich_leases_with_nud_state(leases_with_names).await;
|
|
||||||
(StatusCode::OK, Json(out)).into_response()
|
|
||||||
}
|
|
||||||
Err(e) => ApiError {
|
|
||||||
error: e.to_string(),
|
|
||||||
code: StatusCode::BAD_GATEWAY,
|
|
||||||
}
|
|
||||||
.into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
use crate::route::error::ApiError;
|
|
||||||
use axum::{Json, http::StatusCode, response::IntoResponse};
|
|
||||||
use axum_extra::extract::Query;
|
|
||||||
use macaddr::MacAddr;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_with::skip_serializing_none;
|
|
||||||
use serde_with::{DisplayFromStr, OneOrMany, serde_as};
|
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
use crate::arpparse::NUDState;
|
|
||||||
use crate::utils::query::get_macs;
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Hash, Deserialize, Serialize)]
|
|
||||||
pub struct DeviceQuery {
|
|
||||||
pub name: Option<String>,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub filter: Filters,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Hash, Deserialize)]
|
|
||||||
pub struct NamePath {
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[skip_serializing_none]
|
|
||||||
#[derive(Debug, Default, Serialize)]
|
|
||||||
pub struct Status<T> {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub table: Vec<T>,
|
|
||||||
pub filters: Filters,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Debug, Default, Clone, Hash, Serialize, Deserialize)]
|
|
||||||
pub struct Filters {
|
|
||||||
#[serde_as(as = "OneOrMany<_>")]
|
|
||||||
#[serde(default)]
|
|
||||||
pub ips: Vec<IpAddr>,
|
|
||||||
#[serde_as(as = "OneOrMany<_>")]
|
|
||||||
#[serde(default)]
|
|
||||||
pub devs: Vec<String>,
|
|
||||||
#[serde_as(as = "OneOrMany<_>")]
|
|
||||||
#[serde(default)]
|
|
||||||
pub nuds: Vec<NUDState>,
|
|
||||||
#[serde_as(as = "OneOrMany<DisplayFromStr>")]
|
|
||||||
#[serde(default)]
|
|
||||||
pub macs: Vec<MacAddr>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_status_json(
|
|
||||||
Query(DeviceQuery {
|
|
||||||
name,
|
|
||||||
filter: filters,
|
|
||||||
..
|
|
||||||
}): Query<DeviceQuery>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
match get_macs(
|
|
||||||
name.as_slice(),
|
|
||||||
&filters.ips,
|
|
||||||
&filters.devs,
|
|
||||||
&filters.nuds,
|
|
||||||
&filters.macs,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(table) => (
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(Status {
|
|
||||||
name,
|
|
||||||
table,
|
|
||||||
filters,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
Err(error) => ApiError {
|
|
||||||
code: StatusCode::BAD_GATEWAY,
|
|
||||||
error: error
|
|
||||||
.chain()
|
|
||||||
.map(ToString::to_string)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(": "),
|
|
||||||
}
|
|
||||||
.into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Status endpoints
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
//! impls are at [`utils::wake::impl`](crate::utils::wake::r#impl) for some reason
|
|
||||||
use std::io;
|
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
/* use crate::arpparse::IpNeighLine;
|
|
||||||
use crate::route::api::Status; */
|
|
||||||
use crate::utils::parse::mac;
|
|
||||||
use crate::utils::wake::wake_one;
|
|
||||||
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
|
|
||||||
use futures::TryFutureExt;
|
|
||||||
use macaddr::MacAddr;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_with::skip_serializing_none;
|
|
||||||
use tokio::net::UdpSocket;
|
|
||||||
|
|
||||||
#[skip_serializing_none]
|
|
||||||
#[derive(Debug, Default, Serialize, Clone)]
|
|
||||||
pub struct WakeResult {
|
|
||||||
pub success: bool,
|
|
||||||
pub result: Option<Vec<WakeTargetResult>>,
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
#[skip_serializing_none]
|
|
||||||
#[derive(Debug, Serialize, Clone, Copy)]
|
|
||||||
pub struct WakeTargetResult {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub target: WakeTarget,
|
|
||||||
pub status: WakeTargetStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone, Copy, Hash)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum WakeTargetStatus {
|
|
||||||
Succeed,
|
|
||||||
/// not a real address...
|
|
||||||
NonexistentAddress,
|
|
||||||
WrongSize,
|
|
||||||
/// input is not enough
|
|
||||||
Incomplete,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[skip_serializing_none]
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
|
|
||||||
pub struct WakeTarget {
|
|
||||||
#[serde(default)]
|
|
||||||
pub ip: Option<IpAddr>,
|
|
||||||
#[serde(default, with = "mac::option_mac")]
|
|
||||||
pub mac: Option<MacAddr>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn wake_multi(Json(req): Json<Vec<WakeTarget>>) -> impl IntoResponse {
|
|
||||||
match wake_multi_split(req).await {
|
|
||||||
Ok(results) => (
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(WakeResult {
|
|
||||||
success: true,
|
|
||||||
result: Some(results),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Err(error) => {
|
|
||||||
let error = Some(format!("Error: {}", error));
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(WakeResult {
|
|
||||||
success: false,
|
|
||||||
error,
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// this is so bad
|
|
||||||
pub async fn wake_multi_split(
|
|
||||||
targets: impl IntoIterator<Item = WakeTarget>,
|
|
||||||
) -> io::Result<Vec<WakeTargetResult>> {
|
|
||||||
let sock = UdpSocket::bind("[::]:0")
|
|
||||||
.or_else(|_| UdpSocket::bind(":0"))
|
|
||||||
.await?;
|
|
||||||
sock.set_broadcast(true)?;
|
|
||||||
|
|
||||||
let iter = targets.into_iter().map(async |c| {
|
|
||||||
if c.is_incomplete() {
|
|
||||||
c.to_incomplete()
|
|
||||||
} else {
|
|
||||||
let t = c.try_into().expect("complete struct failed to try_into");
|
|
||||||
wake_one(&sock, t).await.into()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Ok(futures::future::join_all(iter).await)
|
|
||||||
}
|
|
||||||
/* #[derive(Debug, Serialize)]
|
|
||||||
pub struct WakeStatusLine {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub status: IpNeighLine, // most powerful find of the century
|
|
||||||
pub wake_status: WakeTargetStatus,
|
|
||||||
}
|
|
||||||
pub type WakeStatus = Status<WakeStatusLine>; */
|
|
||||||
// /// return status BUT plus a indicator of i sent a wake.
|
|
||||||
// pub async fn wake_status(
|
|
||||||
// Query(DeviceQuery {
|
|
||||||
// name,
|
|
||||||
// ip,
|
|
||||||
// dev,
|
|
||||||
// nud,
|
|
||||||
// mac,
|
|
||||||
// ..
|
|
||||||
// }): Query<DeviceQuery>,
|
|
||||||
// ) -> impl IntoResponse {
|
|
||||||
// }
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use wakey_core::InterfaceSummary;
|
||||||
|
|
||||||
|
/// Return interface names only.
|
||||||
|
///
|
||||||
|
/// This is the old, lightweight interface listing surface kept for compatibility.
|
||||||
|
pub async fn list_interfaces() -> Result<Vec<String>> {
|
||||||
|
Ok(wakey_linux::devices::devs_sorted().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return condensed interface summaries useful for CLI and wake routing.
|
||||||
|
pub async fn get_interface_summaries() -> Result<Vec<InterfaceSummary>> {
|
||||||
|
wakey_linux::devices::list_interface_summaries().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return one named interface summary when present.
|
||||||
|
pub async fn get_interface_summary(name: &str) -> Result<Option<InterfaceSummary>> {
|
||||||
|
Ok(get_interface_summaries()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|iface| iface.ifname == name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a hostname through the local resolver and collect all returned IPs.
|
||||||
|
pub async fn get_ips(name: impl AsRef<str>) -> Result<Vec<std::net::IpAddr>> {
|
||||||
|
Ok(wakey_linux::devices::get_ips(name.as_ref())
|
||||||
|
.await?
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use wakey_core::{
|
||||||
|
Device, DeviceInventory, DeviceQuery, DhcpLease, DhcpLeaseWithState, NeighborEntry, Presence,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::service::leases::get_leases;
|
||||||
|
use crate::service::query::resolve_query;
|
||||||
|
|
||||||
|
/// Resolve free-form input and return merged devices rather than raw source rows.
|
||||||
|
pub async fn resolve_devices(input: impl Into<String>) -> Result<Vec<Device>> {
|
||||||
|
let query = resolve_query(input).await?;
|
||||||
|
inventory(query).await.map(|inventory| inventory.devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a merged device inventory from neighbor-table and DHCP-lease sources.
|
||||||
|
///
|
||||||
|
/// This is the current center of gravity for the service layer. Higher-level
|
||||||
|
/// status and wake flows should prefer deriving from inventory rather than
|
||||||
|
/// directly from raw Linux source rows.
|
||||||
|
pub async fn inventory(query: DeviceQuery) -> Result<DeviceInventory> {
|
||||||
|
let neighbors = wakey_linux::devices::query_status(&query).await?;
|
||||||
|
let leases = get_leases(wakey_core::LeaseQuery {
|
||||||
|
include_state: false,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(DeviceInventory {
|
||||||
|
devices: merge_devices(neighbors, leases, &query),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge raw neighbor entries and DHCP leases into device aggregates.
|
||||||
|
///
|
||||||
|
/// Identity is currently MAC-first, with an IP-based fallback when a neighbor
|
||||||
|
/// row does not include a MAC address.
|
||||||
|
pub fn merge_devices(
|
||||||
|
neighbors: Vec<NeighborEntry>,
|
||||||
|
leases: Vec<DhcpLeaseWithState>,
|
||||||
|
query: &DeviceQuery,
|
||||||
|
) -> Vec<Device> {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
let mut by_mac: BTreeMap<String, (Vec<NeighborEntry>, Vec<DhcpLease>)> = BTreeMap::new();
|
||||||
|
|
||||||
|
for row in neighbors {
|
||||||
|
let key = row
|
||||||
|
.mac
|
||||||
|
.map(|m| m.to_string())
|
||||||
|
.unwrap_or_else(|| format!("ip:{}", row.ip));
|
||||||
|
by_mac.entry(key).or_default().0.push(row);
|
||||||
|
}
|
||||||
|
for lease in leases {
|
||||||
|
let key = lease.lease_line.mac.to_string();
|
||||||
|
by_mac.entry(key).or_default().1.push(lease.lease_line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut devices: Vec<Device> = by_mac
|
||||||
|
.into_values()
|
||||||
|
.map(|(neighbors, leases)| Device::from_parts(neighbors, leases))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Some(name) = &query.name {
|
||||||
|
devices.retain(|device| device.names.iter().any(|n| n == name));
|
||||||
|
}
|
||||||
|
if !query.filter.devs.is_empty() {
|
||||||
|
devices.retain(|device| {
|
||||||
|
device
|
||||||
|
.interfaces
|
||||||
|
.iter()
|
||||||
|
.any(|iface| query.filter.devs.contains(iface))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !query.filter.ips.is_empty() {
|
||||||
|
devices.retain(|device| device.ips.iter().any(|ip| query.filter.ips.contains(ip)));
|
||||||
|
}
|
||||||
|
if !query.filter.macs.is_empty() {
|
||||||
|
devices.retain(|device| {
|
||||||
|
device
|
||||||
|
.macs
|
||||||
|
.iter()
|
||||||
|
.any(|mac| query.filter.macs.contains(mac))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !query.filter.nuds.is_empty() {
|
||||||
|
devices.retain(|device| {
|
||||||
|
device
|
||||||
|
.neighbors
|
||||||
|
.iter()
|
||||||
|
.any(|neighbor| query.filter.nuds.contains(&neighbor.state))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.sort_by(|a, b| {
|
||||||
|
presence_rank(b.presence)
|
||||||
|
.cmp(&presence_rank(a.presence))
|
||||||
|
.then_with(|| a.names.first().cmp(&b.names.first()))
|
||||||
|
});
|
||||||
|
devices
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn presence_rank(presence: Presence) -> u8 {
|
||||||
|
match presence {
|
||||||
|
Presence::Online => 3,
|
||||||
|
Presence::LikelyOnline => 2,
|
||||||
|
Presence::Unknown => 1,
|
||||||
|
Presence::Offline => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use wakey_core::{DhcpLease, DhcpLeaseWithState, LeaseQuery};
|
||||||
|
|
||||||
|
/// Read DHCP leases and optionally enrich them with current neighbor-state data.
|
||||||
|
pub async fn get_leases(query: LeaseQuery) -> Result<Vec<DhcpLeaseWithState>> {
|
||||||
|
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
|
||||||
|
.await
|
||||||
|
.context("failed to read DHCP leases")?;
|
||||||
|
if query.include_state {
|
||||||
|
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
|
||||||
|
} else {
|
||||||
|
Ok(leases_without_state(leases))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap raw DHCP leases in the current service output shape without neighbor state.
|
||||||
|
pub fn leases_without_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
|
||||||
|
leases
|
||||||
|
.into_iter()
|
||||||
|
.map(|lease_line| DhcpLeaseWithState {
|
||||||
|
lease_line,
|
||||||
|
nud_state: None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
pub mod interfaces;
|
||||||
|
pub mod inventory;
|
||||||
|
pub mod leases;
|
||||||
|
pub mod query;
|
||||||
|
pub mod status;
|
||||||
|
pub mod wake;
|
||||||
|
|
||||||
|
pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips, list_interfaces};
|
||||||
|
pub use inventory::{inventory, merge_devices, resolve_devices};
|
||||||
|
pub use leases::{get_leases, leases_without_state};
|
||||||
|
pub use query::{query_to_device_query, resolve_query, resolve_selector};
|
||||||
|
pub use status::{StatusResponse, device_to_status_rows, get_status, get_status_for_input};
|
||||||
|
pub use wake::{
|
||||||
|
broadcast_wake_targets, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use wakey_core::{DeviceFilters, DeviceQuery, Query, QueryInput};
|
||||||
|
|
||||||
|
/// Resolve free-form user input into the legacy `DeviceQuery` filter shape.
|
||||||
|
///
|
||||||
|
/// This is the compatibility entrypoint used by CLI and HTTP paths that still
|
||||||
|
/// speak in terms of `DeviceQuery`.
|
||||||
|
pub async fn resolve_query(input: impl Into<String>) -> Result<DeviceQuery> {
|
||||||
|
query_to_device_query(resolve_selector(input).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify one piece of free-form user input into a typed selector.
|
||||||
|
///
|
||||||
|
/// The Linux adapter decides whether the input looks like an IP address, MAC,
|
||||||
|
/// interface name, neighbor state, or plain text.
|
||||||
|
pub async fn resolve_selector(input: impl Into<String>) -> Result<Query> {
|
||||||
|
Ok(
|
||||||
|
match wakey_linux::devices::classify_query(input.into()).await {
|
||||||
|
QueryInput::Ip(ip_addr) => Query::Ip(ip_addr),
|
||||||
|
QueryInput::Mac(mac_addr) => Query::Mac(mac_addr),
|
||||||
|
QueryInput::Dev(dev) => Query::Interface(dev),
|
||||||
|
QueryInput::Nud(state) => Query::NeighborState(state),
|
||||||
|
QueryInput::Name(name) => Query::Text(name),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert the newer selector-oriented `Query` model into a `DeviceQuery`.
|
||||||
|
///
|
||||||
|
/// This keeps the old filter-based service and HTTP surfaces working while the
|
||||||
|
/// internals migrate toward selector- and device-oriented APIs.
|
||||||
|
pub fn query_to_device_query(query: Query) -> Result<DeviceQuery> {
|
||||||
|
Ok(match query {
|
||||||
|
Query::Ip(ip_addr) => DeviceQuery {
|
||||||
|
filter: DeviceFilters {
|
||||||
|
ips: vec![ip_addr],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Query::Mac(mac_addr) => DeviceQuery {
|
||||||
|
filter: DeviceFilters {
|
||||||
|
macs: vec![mac_addr],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Query::Interface(dev) => DeviceQuery {
|
||||||
|
filter: DeviceFilters {
|
||||||
|
devs: vec![dev],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Query::NeighborState(state) => DeviceQuery {
|
||||||
|
filter: DeviceFilters {
|
||||||
|
nuds: vec![state],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Query::Text(name) => DeviceQuery {
|
||||||
|
name: Some(name),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use wakey_core::{Device, DeviceQuery, NeighborEntry, Presence, Status};
|
||||||
|
|
||||||
|
use crate::service::inventory::inventory;
|
||||||
|
use crate::service::query::resolve_query;
|
||||||
|
|
||||||
|
/// Service status payload, still expressed in terms of legacy neighbor rows.
|
||||||
|
pub type StatusResponse = Status<NeighborEntry>;
|
||||||
|
|
||||||
|
/// Return status rows derived from the merged device inventory.
|
||||||
|
///
|
||||||
|
/// This keeps the old status response shape alive while the underlying model is
|
||||||
|
/// increasingly device-centered.
|
||||||
|
pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
|
||||||
|
let inventory = inventory(query.clone()).await?;
|
||||||
|
let table = inventory
|
||||||
|
.devices
|
||||||
|
.iter()
|
||||||
|
.flat_map(device_to_status_rows)
|
||||||
|
.collect();
|
||||||
|
Ok(Status {
|
||||||
|
name: query.name,
|
||||||
|
table,
|
||||||
|
filters: query.filter,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience wrapper around [`get_status`] for free-form user input.
|
||||||
|
pub async fn get_status_for_input(input: impl Into<String>) -> Result<StatusResponse> {
|
||||||
|
let query = resolve_query(input).await?;
|
||||||
|
get_status(query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project a device aggregate back into legacy status rows.
|
||||||
|
///
|
||||||
|
/// If the device already has neighbor rows they are reused directly; otherwise a
|
||||||
|
/// fallback row is synthesized from the best available device data.
|
||||||
|
pub fn device_to_status_rows(device: &Device) -> Vec<NeighborEntry> {
|
||||||
|
if !device.neighbors.is_empty() {
|
||||||
|
return device.neighbors.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallback_mac = device.macs.first().copied();
|
||||||
|
let fallback_dev = device.interfaces.first().cloned();
|
||||||
|
let fallback_state = match device.presence {
|
||||||
|
Presence::Online => wakey_core::NeighborState::Reachable,
|
||||||
|
Presence::LikelyOnline => wakey_core::NeighborState::Stale,
|
||||||
|
Presence::Offline => wakey_core::NeighborState::Failed,
|
||||||
|
Presence::Unknown => wakey_core::NeighborState::None,
|
||||||
|
};
|
||||||
|
|
||||||
|
device
|
||||||
|
.ips
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|ip| NeighborEntry {
|
||||||
|
ip,
|
||||||
|
dev: fallback_dev.clone(),
|
||||||
|
mac: fallback_mac,
|
||||||
|
state: fallback_state,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use wakey_core::{WakeResult, WakeTarget};
|
||||||
|
|
||||||
|
use crate::service::interfaces::get_interface_summaries;
|
||||||
|
use crate::service::inventory::resolve_devices;
|
||||||
|
|
||||||
|
/// Send Wake-on-LAN packets for already-concrete wake targets.
|
||||||
|
pub async fn wake_targets(targets: Vec<WakeTarget>) -> Result<WakeResult> {
|
||||||
|
let result = wakey_linux::wake::wake_many(targets)
|
||||||
|
.await
|
||||||
|
.context("failed to send wake packets")?;
|
||||||
|
Ok(WakeResult { result })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve free-form input into wake targets and send the packets.
|
||||||
|
pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
|
||||||
|
let targets = resolve_wake_targets(input).await?;
|
||||||
|
wake_targets(targets).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build broadcast wake targets for every broadcast-capable interface.
|
||||||
|
///
|
||||||
|
/// This is used by explicit manual wake mode when only a MAC address is supplied.
|
||||||
|
pub async fn broadcast_wake_targets(mac: MacAddr) -> Result<Vec<WakeTarget>> {
|
||||||
|
Ok(get_interface_summaries()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|iface| iface.addrs.into_iter())
|
||||||
|
.filter_map(|addr| addr.broadcast)
|
||||||
|
.map(|ip| WakeTarget {
|
||||||
|
ip: Some(IpAddr::V4(ip)),
|
||||||
|
mac: Some(mac),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake a device explicitly by MAC, optionally targeting a specific IP/broadcast.
|
||||||
|
pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResult> {
|
||||||
|
let targets = match ip {
|
||||||
|
Some(ip) => vec![WakeTarget {
|
||||||
|
ip: Some(ip),
|
||||||
|
mac: Some(mac),
|
||||||
|
}],
|
||||||
|
None => broadcast_wake_targets(mac).await?,
|
||||||
|
};
|
||||||
|
wake_targets(targets).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve free-form input into concrete wake targets.
|
||||||
|
///
|
||||||
|
/// The current resolution strategy fans out one wake target per resolved device IP,
|
||||||
|
/// using the first known MAC address for that device.
|
||||||
|
pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTarget>> {
|
||||||
|
let devices = resolve_devices(input).await?;
|
||||||
|
Ok(devices
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|device| {
|
||||||
|
let mac = device.macs.first().copied();
|
||||||
|
device
|
||||||
|
.ips
|
||||||
|
.into_iter()
|
||||||
|
.map(move |ip| WakeTarget { ip: Some(ip), mac })
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
+1
-2
@@ -5,9 +5,8 @@
|
|||||||
// [0xbc, 0x09, 0x1b, 0xec, 0x65, 0xd0],
|
// [0xbc, 0x09, 0x1b, 0xec, 0x65, 0xd0],
|
||||||
// ];
|
// ];
|
||||||
|
|
||||||
/// is it time to lookup host lda.lan for this...
|
// is it time to lookup host lda.lan for this...
|
||||||
// pub static LDA_MACS_2: LazyLock<[MacAddr; 2]> = LazyLock::new(|| LDA_MACS.map(MacAddr::from));
|
// pub static LDA_MACS_2: LazyLock<[MacAddr; 2]> = LazyLock::new(|| LDA_MACS.map(MacAddr::from));
|
||||||
pub mod wake;
|
|
||||||
|
|
||||||
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
|
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
|
||||||
// this is so bad
|
// this is so bad
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/// key for yes: "1" | "true" | "yes" | "on" | "y"
|
||||||
|
///
|
||||||
|
/// frfr
|
||||||
|
pub fn _de_boolish<'de, D>(des: D) -> Result<bool, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
use serde::Deserialize;
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum Boolish {
|
||||||
|
B(bool),
|
||||||
|
I(u8),
|
||||||
|
S(String),
|
||||||
|
}
|
||||||
|
Ok(match Boolish::deserialize(des)? {
|
||||||
|
Boolish::B(b) => b,
|
||||||
|
Boolish::I(i) => i != 0,
|
||||||
|
Boolish::S(s) => {
|
||||||
|
let t = s.trim().to_ascii_lowercase();
|
||||||
|
if t.is_empty() {
|
||||||
|
true // presence implies true
|
||||||
|
} else {
|
||||||
|
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a tolerant boolean value from a string.
|
||||||
|
/// Accepts: "1", "true", "yes", "on", "y" as true; "0", "false", "no", "off", "n" as false.
|
||||||
|
/// Empty string means true (presence-only query flag).
|
||||||
|
pub fn boolish_str(s: &str) -> bool {
|
||||||
|
let t = s.trim().to_ascii_lowercase();
|
||||||
|
if t.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|
||||||
|
|| (!matches!(t.as_str(), "0" | "false" | "no" | "off" | "n")
|
||||||
|
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
use macaddr::MacAddr;
|
|
||||||
use serde::{self, Deserialize, Deserializer, de::Error as DeError};
|
|
||||||
use serde::{Serialize, Serializer, de};
|
|
||||||
|
|
||||||
pub fn _serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
|
|
||||||
serde::Serialize::serialize(&strings, serializer)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize a MacAddr as a string
|
|
||||||
pub fn serialize<S>(mac: &MacAddr, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_str(&mac.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialize a MacAddr from a string
|
|
||||||
pub fn _deserialize<'de, D>(deserializer: D) -> Result<MacAddr, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let s = <String as serde::Deserialize>::deserialize(deserializer)?;
|
|
||||||
s.parse::<MacAddr>().map_err(DeError::custom)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod option_mac {
|
|
||||||
use super::*;
|
|
||||||
/// serialize an [`Option<MacAddr>`]
|
|
||||||
pub fn serialize<S: Serializer>(bro: &Option<MacAddr>, ser: S) -> Result<S::Ok, S::Error> {
|
|
||||||
Option::<String>::serialize(&bro.as_ref().map(ToString::to_string), ser)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// deserialize an [`Option<MacAddr>`]
|
|
||||||
pub fn deserialize<'de, D>(des: D) -> Result<Option<MacAddr>, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
{
|
|
||||||
Option::<&str>::deserialize(des)?
|
|
||||||
.map(str::parse)
|
|
||||||
.transpose()
|
|
||||||
.map_err(de::Error::custom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/// Parses Chrome-style numeric IPv4 forms: hex (0x...), decimal, octal.
|
|
||||||
pub fn parse_numeric_ipv4(s: &str) -> Option<std::net::IpAddr> {
|
|
||||||
let s = s.trim();
|
|
||||||
// hex
|
|
||||||
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))
|
|
||||||
&& hex.chars().all(|c| c.is_ascii_hexdigit())
|
|
||||||
&& let Ok(n) = u32::from_str_radix(hex, 16)
|
|
||||||
{
|
|
||||||
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
|
|
||||||
}
|
|
||||||
// decimal
|
|
||||||
if s.chars().all(|c| c.is_ascii_digit())
|
|
||||||
&& let Ok(n) = s.parse::<u32>()
|
|
||||||
{
|
|
||||||
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
|
|
||||||
}
|
|
||||||
// octal (leading 0, all octal digits)
|
|
||||||
if s.len() > 1
|
|
||||||
&& s.as_bytes()[0] == b'0'
|
|
||||||
&& s.chars().all(|c| matches!(c, '0'..='7'))
|
|
||||||
&& let Ok(n) = u32::from_str_radix(s, 8)
|
|
||||||
{
|
|
||||||
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
/// Extracts the host portion from a URL-like string, for smart input parsing.
|
|
||||||
pub fn extract_host(input: &str) -> &str {
|
|
||||||
let mut s = input.trim();
|
|
||||||
// Strip scheme (e.g., http://, https://, ssh://) or network-path reference (//host)
|
|
||||||
if let Some(idx) = s.find("://") {
|
|
||||||
s = &s[idx + 3..];
|
|
||||||
} else if let Some(rest) = s.strip_prefix("//") {
|
|
||||||
s = rest;
|
|
||||||
}
|
|
||||||
// Strip potential userinfo (user@host)
|
|
||||||
if let Some((_, host)) = s.rsplit_once('@') {
|
|
||||||
s = host;
|
|
||||||
}
|
|
||||||
// If bracketed IPv6 like [::1]:8080/path -> extract inside brackets
|
|
||||||
if let Some(host) = s.strip_prefix('[') {
|
|
||||||
if let Some(end) = host.find(']') {
|
|
||||||
s = &host[..end];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Trim path suffix if any
|
|
||||||
if let Some(pos) = s.find('/') {
|
|
||||||
s = &s[..pos];
|
|
||||||
}
|
|
||||||
// Drop trailing :port if present and numeric, but only if there's exactly one ':'
|
|
||||||
if let Some((host, port)) = s.rsplit_once(':')
|
|
||||||
&& s.matches(':').count() == 1
|
|
||||||
&& port.chars().all(|c| c.is_ascii_digit())
|
|
||||||
{
|
|
||||||
s = host;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// key for yes: "1" | "true" | "yes" | "on" | "y"
|
|
||||||
///
|
|
||||||
/// frfr
|
|
||||||
pub fn _de_boolish<'de, D>(des: D) -> Result<bool, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
{
|
|
||||||
use serde::Deserialize;
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
enum Boolish {
|
|
||||||
B(bool),
|
|
||||||
I(u8),
|
|
||||||
S(String),
|
|
||||||
}
|
|
||||||
Ok(match Boolish::deserialize(des)? {
|
|
||||||
Boolish::B(b) => b,
|
|
||||||
Boolish::I(i) => i != 0,
|
|
||||||
Boolish::S(s) => {
|
|
||||||
let t = s.trim().to_ascii_lowercase();
|
|
||||||
if t.is_empty() {
|
|
||||||
true // presence implies true
|
|
||||||
} else {
|
|
||||||
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a tolerant boolean value from a string.
|
|
||||||
/// Accepts: "1", "true", "yes", "on", "y" as true; "0", "false", "no", "off", "n" as false.
|
|
||||||
/// Empty string means true (presence-only query flag).
|
|
||||||
pub fn boolish_str(s: &str) -> bool {
|
|
||||||
let t = s.trim().to_ascii_lowercase();
|
|
||||||
if t.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|
|
||||||
|| (!matches!(t.as_str(), "0" | "false" | "no" | "off" | "n")
|
|
||||||
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod mac;
|
|
||||||
+1
-1
@@ -7,7 +7,7 @@ use tokio::{
|
|||||||
time::timeout,
|
time::timeout,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{arpparse::NUDState, utils::query::get_mac};
|
use crate::{legacy::arpparse::NUDState, utils::query::get_mac};
|
||||||
|
|
||||||
pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
|
pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
|
||||||
timeout(Duration::from_secs(1), TcpStream::connect(addr))
|
timeout(Duration::from_secs(1), TcpStream::connect(addr))
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
pub mod dev {
|
||||||
|
pub use wakey_linux::devices::devs_sorted;
|
||||||
|
}
|
||||||
|
pub mod leases {
|
||||||
|
pub use wakey_linux::dhcp::enrich_leases_with_nud_state;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod parser {
|
||||||
|
pub use wakey_core::QueryInput as QueryType;
|
||||||
|
pub use wakey_linux::devices::classify_query as parse_query;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use leases::*;
|
||||||
|
pub use macs::*;
|
||||||
|
|
||||||
|
pub mod macs {
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use crate::legacy::arpparse::{IpNeighLine, NUDState};
|
||||||
|
|
||||||
|
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
|
||||||
|
wakey_linux::devices::get_ips(machine_name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_macs(
|
||||||
|
machine_names: &[impl AsRef<str>],
|
||||||
|
ips: &[IpAddr],
|
||||||
|
devs: &[impl AsRef<str>],
|
||||||
|
state: &[NUDState],
|
||||||
|
macs: &[macaddr::MacAddr],
|
||||||
|
) -> Result<Vec<IpNeighLine>> {
|
||||||
|
wakey_linux::devices::get_neighbors(machine_names, ips, devs, state, macs).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_mac(
|
||||||
|
ip: Option<IpAddr>,
|
||||||
|
dev: Option<&str>,
|
||||||
|
state: &[NUDState],
|
||||||
|
) -> Result<Vec<IpNeighLine>> {
|
||||||
|
let ips: Vec<IpAddr> = ip.into_iter().collect();
|
||||||
|
let devs: Vec<&str> = dev.into_iter().collect();
|
||||||
|
get_macs(&[] as &[&str], &ips, &devs, state, &[]).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
// /// 50ms
|
|
||||||
// pub async fn get_dev() -> HashSet<String> {
|
|
||||||
// use lda_ipjs::subcommands::address::json as ipjs_json;
|
|
||||||
// let mut devs: HashSet<String> = HashSet::new();
|
|
||||||
// if let Ok(items) = ipjs_json::get(None).await {
|
|
||||||
// for item in items {
|
|
||||||
// if item.ifname != "lo" && !item.ifname.is_empty() {
|
|
||||||
// devs.insert(item.ifname);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// devs
|
|
||||||
// }
|
|
||||||
|
|
||||||
/// 3ms
|
|
||||||
pub async fn get_dev() -> HashSet<String> {
|
|
||||||
use std::fs;
|
|
||||||
fn get_dev() -> HashSet<String> {
|
|
||||||
let mut devs: HashSet<String> = HashSet::new();
|
|
||||||
if let Ok(rd) = std::fs::read_dir("/sys/class/net") {
|
|
||||||
for e in rd.flatten() {
|
|
||||||
if e.file_type()
|
|
||||||
.map(|ft| {
|
|
||||||
if ft.is_symlink() {
|
|
||||||
// true
|
|
||||||
fs::metadata(e.path()).map(|m| m.is_dir()).unwrap_or(false)
|
|
||||||
} else {
|
|
||||||
ft.is_dir()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
&& let Ok(name) = e.file_name().into_string()
|
|
||||||
&& name != "lo"
|
|
||||||
&& !name.is_empty()
|
|
||||||
{
|
|
||||||
devs.insert(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let Ok(txt) = std::fs::read_to_string("/proc/net/dev") {
|
|
||||||
for line in txt.lines().skip(2) {
|
|
||||||
if let Some((name, _rest)) = line.split_once(':') {
|
|
||||||
let n = name.trim().to_string();
|
|
||||||
if n != "lo" && !n.is_empty() {
|
|
||||||
devs.insert(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
devs
|
|
||||||
}
|
|
||||||
tokio::task::spawn_blocking(get_dev)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn devs_sorted() -> Vec<String> {
|
|
||||||
let mut v: Vec<String> = get_dev().await.into_iter().collect();
|
|
||||||
v.sort();
|
|
||||||
v
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn has_dev(name: &str) -> bool {
|
|
||||||
get_dev().await.contains(name)
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
use crate::arpparse::NUDState;
|
|
||||||
use crate::dhcpparse::DhcpLeaseLine;
|
|
||||||
use crate::utils::query::get_macs;
|
|
||||||
use serde_with::skip_serializing_none;
|
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
#[skip_serializing_none]
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
|
||||||
pub struct DhcpLeaseOut {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub lease_line: DhcpLeaseLine,
|
|
||||||
pub nud_state: Option<NUDState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enrich DHCP leases with NUD state and rank using get_macs
|
|
||||||
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLeaseLine>) -> Vec<DhcpLeaseOut> {
|
|
||||||
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();
|
|
||||||
let mut map: std::collections::HashMap<IpAddr, NUDState> = std::collections::HashMap::new();
|
|
||||||
if let Ok(rows) = get_macs(&[] as &[&str], &ips, &[] as &[&str], &[], &[]).await {
|
|
||||||
for row in rows {
|
|
||||||
let state = row.state;
|
|
||||||
let r = state.rank();
|
|
||||||
map.entry(row.ip)
|
|
||||||
.and_modify(|e| {
|
|
||||||
let er = e.rank();
|
|
||||||
if r > er {
|
|
||||||
*e = state
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.or_insert(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
leases
|
|
||||||
.into_iter()
|
|
||||||
.map(|lease_line| {
|
|
||||||
let nud_state = map.get(&lease_line.ip).copied();
|
|
||||||
DhcpLeaseOut {
|
|
||||||
lease_line,
|
|
||||||
nud_state,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
use lda_ipjs::subcommands::neighbor;
|
|
||||||
use macaddr::MacAddr;
|
|
||||||
|
|
||||||
use crate::arpparse::{self, IpNeighLine, NUDState};
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
|
|
||||||
Ok(tokio::net::lookup_host((machine_name, 0))
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("DNS resolve failed for {machine_name}"))?
|
|
||||||
.map(|c| c.ip()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// good now
|
|
||||||
//
|
|
||||||
// Current logic: When filtering by exactly 1 dev/mac, exclude entries missing that field.
|
|
||||||
// This is because missing dev/mac usually means the entry is incomplete/transient.
|
|
||||||
//
|
|
||||||
// when there is only one MACs (getmac got some), the result will not have them fields.
|
|
||||||
// so there are three cases:
|
|
||||||
//
|
|
||||||
// 1. dont got nothing: take all of them (macset.is_empty())
|
|
||||||
// 2. exactly one: pre-filtered by ip, everything matches,
|
|
||||||
// devset.len() != 1 returns false, but then it works????
|
|
||||||
// OH THIS fuckass code i added it in the get_mac
|
|
||||||
// 3. devset.len() > 1. if none then absolutely not match,
|
|
||||||
// if some then check with the set; thats normal
|
|
||||||
pub async fn get_macs(
|
|
||||||
machine_names: &[impl AsRef<str>],
|
|
||||||
ips: &[IpAddr],
|
|
||||||
devs: &[impl AsRef<str>],
|
|
||||||
state: &[NUDState],
|
|
||||||
macs: &[MacAddr],
|
|
||||||
) -> Result<Vec<IpNeighLine>> {
|
|
||||||
let mut ip_set: HashSet<IpAddr> = ips.iter().map(|ip| ip.to_canonical()).collect();
|
|
||||||
let ip_m: HashSet<IpAddr> = if !machine_names.is_empty() {
|
|
||||||
futures::future::try_join_all(machine_names.iter().map(|c| get_ips(c.as_ref())))
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
Default::default()
|
|
||||||
};
|
|
||||||
let ip_all = if ip_set.is_empty() && ip_m.is_empty() {
|
|
||||||
None
|
|
||||||
} else if ip_set.is_empty() {
|
|
||||||
Some(ip_m)
|
|
||||||
} else if ip_m.is_empty() {
|
|
||||||
Some(ip_set)
|
|
||||||
} else {
|
|
||||||
Some({
|
|
||||||
ip_set.retain(|c| ip_m.contains(c)); // inline AHHH
|
|
||||||
ip_set
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let opt_dev = if devs.len() > 1 {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
devs.iter().next().map(AsRef::as_ref)
|
|
||||||
};
|
|
||||||
|
|
||||||
let run_one = |to_ip: Option<IpAddr>| get_mac(to_ip, opt_dev, state);
|
|
||||||
|
|
||||||
let mut ip_filtered = if let Some(something) = ip_all {
|
|
||||||
if something.len() == 1 {
|
|
||||||
run_one(something.into_iter().next()).await?
|
|
||||||
} else {
|
|
||||||
run_one(None)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.filter(|c| something.contains(&c.ip))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
run_one(None).await?
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply additional filters if any were provided
|
|
||||||
if !devs.is_empty() || !macs.is_empty() {
|
|
||||||
let devset: HashSet<_> = devs.iter().map(AsRef::as_ref).collect();
|
|
||||||
let macset: HashSet<_> = macs.iter().collect();
|
|
||||||
|
|
||||||
ip_filtered.retain(|entry| {
|
|
||||||
// Dev filter: if we're filtering by dev, entry must have a dev AND it must be in the set
|
|
||||||
let dev_ok =
|
|
||||||
devset.is_empty() || entry.dev.as_deref().is_some_and(|d| devset.contains(d));
|
|
||||||
|
|
||||||
// MAC filter: if we're filtering by MAC, entry must have a MAC AND it must be in the set
|
|
||||||
let mac_ok = macset.is_empty() || entry.mac.is_some_and(|m| macset.contains(&m));
|
|
||||||
|
|
||||||
dev_ok && mac_ok
|
|
||||||
})
|
|
||||||
};
|
|
||||||
Ok(ip_filtered)
|
|
||||||
}
|
|
||||||
|
|
||||||
// /// the atomic get_macs. handle ONE thing only.
|
|
||||||
// // this one sucks shit
|
|
||||||
// pub async fn get_mac(
|
|
||||||
// ip: Option<IpAddr>,
|
|
||||||
// dev: Option<&str>,
|
|
||||||
// state: &[NUDState],
|
|
||||||
// ) -> Result<Vec<IpNeighLine>> {
|
|
||||||
// use lda_ipjs::subcommands::neighbor as ipjs_neigh;
|
|
||||||
// let ipjs_states: Vec<ipjs_neigh::NUDState> = state.iter().copied().map(Into::into).collect();
|
|
||||||
|
|
||||||
// let items = ipjs_neigh::json::get(ip, dev, &ipjs_states)
|
|
||||||
// .await
|
|
||||||
// .context("Calling ip -j neigh failed")?;
|
|
||||||
|
|
||||||
// let lines = items.into_iter().map(Into::into).collect();
|
|
||||||
|
|
||||||
// Ok(lines)
|
|
||||||
// }
|
|
||||||
|
|
||||||
/// the atomic get_macs. handle ONE thing only.
|
|
||||||
// 17 - 25 ms full
|
|
||||||
pub async fn _get_mac(
|
|
||||||
ip: Option<IpAddr>,
|
|
||||||
dev: Option<&str>,
|
|
||||||
state: &[NUDState],
|
|
||||||
) -> Result<Vec<IpNeighLine>> {
|
|
||||||
let mut args: Vec<String> = vec!["neigh".into(), "show".into()];
|
|
||||||
if let Some(ip) = ip {
|
|
||||||
args.push("to".into());
|
|
||||||
args.push(ip.to_string());
|
|
||||||
}
|
|
||||||
if let Some(d) = dev {
|
|
||||||
args.push("dev".into());
|
|
||||||
args.push(d.to_string());
|
|
||||||
}
|
|
||||||
for nud in state {
|
|
||||||
args.push("nud".into());
|
|
||||||
args.push(nud.as_ip_neigh_arg().into());
|
|
||||||
}
|
|
||||||
let cmd = "ip";
|
|
||||||
let mut u = tokio::process::Command::new(cmd);
|
|
||||||
u.args(args);
|
|
||||||
let out = u.output().await?;
|
|
||||||
|
|
||||||
if !out.status.success() {
|
|
||||||
bail!(String::from_utf8_lossy(&out.stderr).into_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
let lines = String::from_utf8_lossy(&out.stdout);
|
|
||||||
let parsed = lines.lines().flat_map(arpparse::parse_ip_neigh_line);
|
|
||||||
let rows: Vec<IpNeighLine> = if let Some(d) = dev {
|
|
||||||
parsed.map(IpNeighLine::_with_dev(d)).collect()
|
|
||||||
} else {
|
|
||||||
parsed.collect()
|
|
||||||
};
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 15 - 20 ms full
|
|
||||||
pub async fn get_mac(
|
|
||||||
ip: Option<IpAddr>,
|
|
||||||
dev: Option<&str>,
|
|
||||||
state: &[NUDState],
|
|
||||||
) -> Result<Vec<IpNeighLine>> {
|
|
||||||
let state2: Vec<neighbor::NUDState> = state.iter().copied().map(Into::into).collect();
|
|
||||||
Ok(neighbor::nl::get(ip, dev, &state2)
|
|
||||||
.await
|
|
||||||
.context("rtnetlink failed")?
|
|
||||||
.into_iter()
|
|
||||||
.map(Into::into)
|
|
||||||
.collect())
|
|
||||||
// how did i just do that
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
pub mod dev;
|
|
||||||
pub mod leases;
|
|
||||||
pub mod macs;
|
|
||||||
pub mod parser;
|
|
||||||
|
|
||||||
pub use leases::*;
|
|
||||||
pub use macs::*;
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
use macaddr::MacAddr;
|
|
||||||
|
|
||||||
use crate::{arpparse::NUDState, utils::query::dev::has_dev};
|
|
||||||
|
|
||||||
pub enum QueryType {
|
|
||||||
Ip(IpAddr),
|
|
||||||
Mac(MacAddr),
|
|
||||||
Dev(String),
|
|
||||||
Nud(NUDState),
|
|
||||||
Unknown(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn parse_query(q: String) -> QueryType {
|
|
||||||
let s = if cfg!(feature = "very-smart-parsing") {
|
|
||||||
crate::utils::parse::extract_host(&q)
|
|
||||||
} else {
|
|
||||||
q.trim()
|
|
||||||
};
|
|
||||||
// 1) IP
|
|
||||||
let ip = if cfg!(feature = "very-smart-parsing") {
|
|
||||||
crate::utils::parse::parse_numeric_ipv4(s).or_else(|| s.parse::<IpAddr>().ok())
|
|
||||||
} else {
|
|
||||||
s.parse::<IpAddr>().ok()
|
|
||||||
};
|
|
||||||
if let Some(ip) = ip {
|
|
||||||
return QueryType::Ip(ip);
|
|
||||||
}
|
|
||||||
// 2) MAC
|
|
||||||
if let Ok(mac) = s.parse::<MacAddr>() {
|
|
||||||
return QueryType::Mac(mac);
|
|
||||||
}
|
|
||||||
// 3) NUD state (reachable, stale, ...)
|
|
||||||
if let Ok(state) = s.parse::<NUDState>() {
|
|
||||||
return QueryType::Nud(state);
|
|
||||||
}
|
|
||||||
// 4) Known device? prefer dev first
|
|
||||||
if has_dev(s).await {
|
|
||||||
return QueryType::Dev(s.to_string());
|
|
||||||
}
|
|
||||||
// Default: name last // it will fail also
|
|
||||||
QueryType::Unknown(s.to_string())
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
use super::{WakeStatus, WakeTarget, WakeTargetResult};
|
|
||||||
use crate::route::wake::{
|
|
||||||
WakeTarget as RouteWakeTarget, WakeTargetResult as RouteWakeResult,
|
|
||||||
WakeTargetStatus as RouteWakeStatus,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct Incomplete;
|
|
||||||
|
|
||||||
impl TryFrom<RouteWakeTarget> for WakeTarget {
|
|
||||||
type Error = Incomplete;
|
|
||||||
fn try_from(value: RouteWakeTarget) -> Result<Self, Self::Error> {
|
|
||||||
if let RouteWakeTarget {
|
|
||||||
ip: Some(ip),
|
|
||||||
mac: Some(mac),
|
|
||||||
} = value
|
|
||||||
{
|
|
||||||
Ok(Self { ip, mac })
|
|
||||||
} else {
|
|
||||||
Err(Incomplete)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WakeTarget> for RouteWakeTarget {
|
|
||||||
fn from(WakeTarget { ip, mac }: WakeTarget) -> Self {
|
|
||||||
Self {
|
|
||||||
ip: Some(ip),
|
|
||||||
mac: Some(mac),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WakeTargetResult> for RouteWakeResult {
|
|
||||||
fn from(WakeTargetResult { target, status }: WakeTargetResult) -> Self {
|
|
||||||
Self {
|
|
||||||
target: target.into(),
|
|
||||||
status: status.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RouteWakeTarget {
|
|
||||||
pub const fn to_incomplete(self) -> RouteWakeResult {
|
|
||||||
RouteWakeResult {
|
|
||||||
target: self,
|
|
||||||
status: RouteWakeStatus::Incomplete,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub const fn is_incomplete(&self) -> bool {
|
|
||||||
!matches!(
|
|
||||||
self,
|
|
||||||
Self {
|
|
||||||
ip: Some(_),
|
|
||||||
mac: Some(_)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WakeStatus> for RouteWakeStatus {
|
|
||||||
fn from(value: WakeStatus) -> Self {
|
|
||||||
match value {
|
|
||||||
WakeStatus::NonexistentAddress => Self::NonexistentAddress,
|
|
||||||
WakeStatus::Success => Self::Succeed,
|
|
||||||
WakeStatus::WrongSize => Self::WrongSize,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
//! why did my Head Ass split these into two.
|
|
||||||
|
|
||||||
pub mod impls;
|
|
||||||
use std::{io, net::IpAddr};
|
|
||||||
|
|
||||||
use futures::TryFutureExt;
|
|
||||||
use macaddr::MacAddr;
|
|
||||||
use tokio::net::UdpSocket;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Hash)]
|
|
||||||
pub struct WakeTarget {
|
|
||||||
pub ip: IpAddr,
|
|
||||||
pub mac: MacAddr,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, Copy, Hash)]
|
|
||||||
pub struct WakeTargetResult {
|
|
||||||
pub target: WakeTarget,
|
|
||||||
pub status: WakeStatus,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, Copy, Hash)]
|
|
||||||
pub enum WakeStatus {
|
|
||||||
Success,
|
|
||||||
NonexistentAddress,
|
|
||||||
WrongSize,
|
|
||||||
}
|
|
||||||
impl WakeTarget {
|
|
||||||
const fn _new(ip: IpAddr, mac: MacAddr) -> Self {
|
|
||||||
Self { ip, mac }
|
|
||||||
}
|
|
||||||
const fn good(self) -> WakeTargetResult {
|
|
||||||
WakeTargetResult::new(self, WakeStatus::Success)
|
|
||||||
}
|
|
||||||
const fn bad(self) -> WakeTargetResult {
|
|
||||||
WakeTargetResult::new(self, WakeStatus::WrongSize)
|
|
||||||
}
|
|
||||||
const fn errored(self) -> WakeTargetResult {
|
|
||||||
WakeTargetResult::new(self, WakeStatus::NonexistentAddress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl WakeTargetResult {
|
|
||||||
const fn new(target: WakeTarget, status: WakeStatus) -> Self {
|
|
||||||
Self { target, status }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// its time. we have the ip; the macs. we dont need to send to the uh the broadcast anymore???
|
|
||||||
pub async fn _wake_multi(
|
|
||||||
targets: impl IntoIterator<Item = WakeTarget>,
|
|
||||||
) -> io::Result<Vec<WakeTargetResult>> {
|
|
||||||
let sock = UdpSocket::bind("[::]:0")
|
|
||||||
.or_else(|_| UdpSocket::bind(":0"))
|
|
||||||
.await?;
|
|
||||||
sock.set_broadcast(true)?;
|
|
||||||
let fs = targets.into_iter().map(|t| wake_one(&sock, t));
|
|
||||||
Ok(futures::future::join_all(fs).await)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn wake_one(sock: &UdpSocket, t: WakeTarget) -> WakeTargetResult {
|
|
||||||
let mac = t.mac;
|
|
||||||
let mb = mac.as_bytes();
|
|
||||||
let mut pac = [0; 6 + 6 * 16];
|
|
||||||
pac[..6].fill(0xff);
|
|
||||||
for i in 1..=16 {
|
|
||||||
pac[i * 6..(i + 1) * 6].copy_from_slice(mb);
|
|
||||||
}
|
|
||||||
let ip = t.ip;
|
|
||||||
let port = 9;
|
|
||||||
match sock.send_to(&pac, (ip, port)).await {
|
|
||||||
Ok(n) if n == pac.len() => t.good(),
|
|
||||||
Ok(_) => t.bad(),
|
|
||||||
Err(_) => t.errored(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pub async fn wake_query();
|
|
||||||
@@ -33,7 +33,7 @@ export async function sendWake(name) {
|
|||||||
|
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
|
|
||||||
if (!j.success) {
|
if (j.error) {
|
||||||
elLog.textContent = `wake error: ${j.error}`;
|
elLog.textContent = `wake error: ${j.error}`;
|
||||||
setPill("bad", "error");
|
setPill("bad", "error");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use wakey::inventory;
|
||||||
|
use wakey_core::DeviceQuery;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
|
||||||
|
async fn inventory_real_router_prints_device_inventory() -> anyhow::Result<()> {
|
||||||
|
let inventory = inventory(DeviceQuery::default()).await?;
|
||||||
|
println!("{}", serde_json::to_string_pretty(&inventory)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use wakey::{broadcast_wake_targets, get_interface_summaries, get_status, get_status_for_input};
|
||||||
|
use wakey_core::{DeviceFilters, DeviceQuery};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
|
||||||
|
async fn interfaces_real_router_prints_interface_summaries() -> anyhow::Result<()> {
|
||||||
|
let interfaces = get_interface_summaries().await?;
|
||||||
|
assert!(
|
||||||
|
!interfaces.is_empty(),
|
||||||
|
"expected at least one non-loopback interface summary"
|
||||||
|
);
|
||||||
|
println!("{}", serde_json::to_string_pretty(&interfaces)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
|
||||||
|
async fn status_real_router_default_query_returns_rows_or_empty_cleanly() -> anyhow::Result<()> {
|
||||||
|
let status = get_status(DeviceQuery::default()).await?;
|
||||||
|
println!("{}", serde_json::to_string_pretty(&status)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
|
||||||
|
async fn status_real_router_for_interface_filter_succeeds() -> anyhow::Result<()> {
|
||||||
|
let interfaces = get_interface_summaries().await?;
|
||||||
|
let first = interfaces
|
||||||
|
.first()
|
||||||
|
.map(|iface| iface.ifname.clone())
|
||||||
|
.expect("expected at least one interface");
|
||||||
|
|
||||||
|
let status = get_status(DeviceQuery {
|
||||||
|
name: None,
|
||||||
|
filter: DeviceFilters {
|
||||||
|
devs: vec![first.clone()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("filtered dev: {first}");
|
||||||
|
println!("{}", serde_json::to_string_pretty(&status)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
|
||||||
|
async fn status_real_router_string_input_for_interface_succeeds() -> anyhow::Result<()> {
|
||||||
|
let interfaces = get_interface_summaries().await?;
|
||||||
|
let first = interfaces
|
||||||
|
.first()
|
||||||
|
.map(|iface| iface.ifname.clone())
|
||||||
|
.expect("expected at least one interface");
|
||||||
|
|
||||||
|
let status = get_status_for_input(first.clone()).await?;
|
||||||
|
println!("selector: {first}");
|
||||||
|
println!("{}", serde_json::to_string_pretty(&status)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
|
||||||
|
async fn broadcast_wake_targets_real_router_resolve_from_interfaces() -> anyhow::Result<()> {
|
||||||
|
let mac: macaddr::MacAddr = "aa:bb:cc:dd:ee:ff".parse()?;
|
||||||
|
let targets = broadcast_wake_targets(mac).await?;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
targets.iter().all(|target| target.mac == Some(mac)),
|
||||||
|
"all broadcast targets should preserve the requested MAC"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
targets.iter().all(|target| matches!(target.ip, Some(IpAddr::V4(_)))),
|
||||||
|
"broadcast targets should be IPv4 broadcast destinations"
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string_pretty(&targets)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "wakey-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_with = { version = "3", features = ["json"] }
|
||||||
|
strum = { version = "0", features = ["derive", "strum_macros"] }
|
||||||
|
thiserror = "2"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod model;
|
||||||
|
pub mod parse;
|
||||||
|
|
||||||
|
pub use model::*;
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_with::skip_serializing_none;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use crate::model::{DhcpLease, NeighborEntry, NeighborState};
|
||||||
|
use crate::parse::mac;
|
||||||
|
|
||||||
|
/// Product-level presence derived from raw neighbor state.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash, Serialize, serde::Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Presence {
|
||||||
|
Online,
|
||||||
|
LikelyOnline,
|
||||||
|
#[default]
|
||||||
|
Unknown,
|
||||||
|
Offline,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NeighborState> for Presence {
|
||||||
|
fn from(value: NeighborState) -> Self {
|
||||||
|
match value {
|
||||||
|
NeighborState::Permanent | NeighborState::Reachable => Self::Online,
|
||||||
|
NeighborState::Stale => Self::LikelyOnline,
|
||||||
|
NeighborState::Failed => Self::Offline,
|
||||||
|
NeighborState::Delay
|
||||||
|
| NeighborState::Probe
|
||||||
|
| NeighborState::Incomplete
|
||||||
|
| NeighborState::Noarp
|
||||||
|
| NeighborState::None => Self::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MAC-first identifier for a device aggregate.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize)]
|
||||||
|
pub struct DeviceId {
|
||||||
|
#[serde(with = "mac")]
|
||||||
|
pub mac: MacAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merged view of one discovered network identity.
|
||||||
|
///
|
||||||
|
/// This aggregates facts from DHCP leases and neighbor-table rows into a more
|
||||||
|
/// useful application-level shape.
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Device {
|
||||||
|
pub id: Option<DeviceId>,
|
||||||
|
pub names: Vec<String>,
|
||||||
|
pub ips: Vec<IpAddr>,
|
||||||
|
#[serde(with = "mac::vec_mac")]
|
||||||
|
pub macs: Vec<MacAddr>,
|
||||||
|
pub interfaces: Vec<String>,
|
||||||
|
pub neighbors: Vec<NeighborEntry>,
|
||||||
|
pub leases: Vec<DhcpLease>,
|
||||||
|
pub presence: Presence,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Device {
|
||||||
|
/// Merge raw neighbor and DHCP facts into one device aggregate.
|
||||||
|
pub fn from_parts(neighbors: Vec<NeighborEntry>, leases: Vec<DhcpLease>) -> Self {
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
let mut names = BTreeSet::new();
|
||||||
|
let mut ips = BTreeSet::new();
|
||||||
|
let mut macs = BTreeSet::new();
|
||||||
|
let mut interfaces = BTreeSet::new();
|
||||||
|
let mut presence = Presence::Unknown;
|
||||||
|
|
||||||
|
for lease in &leases {
|
||||||
|
ips.insert(lease.ip);
|
||||||
|
macs.insert(lease.mac);
|
||||||
|
if let Some(name) = &lease.name {
|
||||||
|
names.insert(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for neighbor in &neighbors {
|
||||||
|
ips.insert(neighbor.ip);
|
||||||
|
if let Some(mac) = neighbor.mac {
|
||||||
|
macs.insert(mac);
|
||||||
|
}
|
||||||
|
if let Some(dev) = &neighbor.dev {
|
||||||
|
interfaces.insert(dev.clone());
|
||||||
|
}
|
||||||
|
presence = std::cmp::max(
|
||||||
|
presence_rank(presence),
|
||||||
|
presence_rank(neighbor.state.into()),
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let macs: Vec<MacAddr> = macs.into_iter().collect();
|
||||||
|
Self {
|
||||||
|
id: macs.first().copied().map(|mac| DeviceId { mac }),
|
||||||
|
names: names.into_iter().collect(),
|
||||||
|
ips: ips.into_iter().collect(),
|
||||||
|
macs,
|
||||||
|
interfaces: interfaces.into_iter().collect(),
|
||||||
|
neighbors,
|
||||||
|
leases,
|
||||||
|
presence,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn presence_rank(presence: Presence) -> u8 {
|
||||||
|
match presence {
|
||||||
|
Presence::Online => 3,
|
||||||
|
Presence::LikelyOnline => 2,
|
||||||
|
Presence::Unknown => 1,
|
||||||
|
Presence::Offline => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u8> for Presence {
|
||||||
|
fn from(value: u8) -> Self {
|
||||||
|
match value {
|
||||||
|
3 => Self::Online,
|
||||||
|
2 => Self::LikelyOnline,
|
||||||
|
0 => Self::Offline,
|
||||||
|
_ => Self::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collection of merged discovered devices.
|
||||||
|
#[derive(Debug, Default, Clone, Serialize)]
|
||||||
|
pub struct DeviceInventory {
|
||||||
|
pub devices: Vec<Device>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::skip_serializing_none;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use crate::model::NeighborState;
|
||||||
|
use crate::parse::mac;
|
||||||
|
|
||||||
|
/// One parsed DHCP lease row.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DhcpLease {
|
||||||
|
pub expires_epoch: u64,
|
||||||
|
pub ip: IpAddr,
|
||||||
|
#[serde(with = "mac")]
|
||||||
|
pub mac: MacAddr,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DHCP lease row plus optional current neighbor-state enrichment.
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DhcpLeaseWithState {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub lease_line: DhcpLease,
|
||||||
|
pub nud_state: Option<NeighborState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options for lease retrieval from the service layer.
|
||||||
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LeaseQuery {
|
||||||
|
pub include_state: bool,
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_with::skip_serializing_none;
|
||||||
|
|
||||||
|
use crate::parse::mac;
|
||||||
|
|
||||||
|
/// A condensed, operator-oriented view of one network interface.
|
||||||
|
///
|
||||||
|
/// This is intentionally smaller than the full Linux `ip address show` / `ip link show`
|
||||||
|
/// payload. It keeps the fields that are currently useful to `wakey`:
|
||||||
|
/// interface identity, operational state, MAC address, bound addresses, and
|
||||||
|
/// IPv4 broadcast targets.
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct InterfaceSummary {
|
||||||
|
/// Kernel interface index.
|
||||||
|
pub ifindex: u32,
|
||||||
|
/// Interface name such as `br-lan`, `eth0`, or `wlan0`.
|
||||||
|
pub ifname: String,
|
||||||
|
/// Lowercased operational state such as `up`, `down`, or `unknown`.
|
||||||
|
pub operstate: String,
|
||||||
|
#[serde(with = "mac::option_mac")]
|
||||||
|
/// Link-layer address when one exists.
|
||||||
|
pub mac: Option<MacAddr>,
|
||||||
|
/// Interface-bound addresses projected into a smaller usable shape.
|
||||||
|
pub addrs: Vec<InterfaceAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A condensed view of one bound interface address.
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct InterfaceAddr {
|
||||||
|
/// Address family such as `inet` or `inet6`.
|
||||||
|
pub family: Option<String>,
|
||||||
|
/// CIDR notation such as `192.168.1.1/24`.
|
||||||
|
pub cidr: Option<String>,
|
||||||
|
/// IPv4 broadcast target when Linux reports one.
|
||||||
|
pub broadcast: Option<std::net::Ipv4Addr>,
|
||||||
|
/// Linux-reported address scope, for example `global` or `link`.
|
||||||
|
pub scope: Option<String>,
|
||||||
|
/// Optional Linux label for the address entry.
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
mod device;
|
||||||
|
mod dhcp;
|
||||||
|
mod interface;
|
||||||
|
mod neighbor;
|
||||||
|
mod query;
|
||||||
|
mod status;
|
||||||
|
mod wake;
|
||||||
|
|
||||||
|
pub use device::{Device, DeviceId, DeviceInventory, Presence};
|
||||||
|
pub use dhcp::{DhcpLease, DhcpLeaseWithState, LeaseQuery};
|
||||||
|
pub use interface::{InterfaceAddr, InterfaceSummary};
|
||||||
|
pub use neighbor::{NeighborEntry, NeighborParseError, NeighborState, parse_neighbor_line};
|
||||||
|
pub use query::{DeviceFilters, DeviceQuery, NamePath, Query, QueryInput};
|
||||||
|
pub use status::Status;
|
||||||
|
pub use wake::{WakeResult, WakeStatus, WakeTarget, WakeTargetResult};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::skip_serializing_none;
|
||||||
|
use std::{net::IpAddr, str::FromStr};
|
||||||
|
use strum::{Display, EnumString};
|
||||||
|
|
||||||
|
use crate::parse::mac;
|
||||||
|
|
||||||
|
/// One neighbor-table row, typically derived from `ip neigh` or netlink.
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize)]
|
||||||
|
pub struct NeighborEntry {
|
||||||
|
pub ip: IpAddr,
|
||||||
|
pub dev: Option<String>,
|
||||||
|
#[serde(with = "mac::option_mac")]
|
||||||
|
pub mac: Option<MacAddr>,
|
||||||
|
pub state: NeighborState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linux neighbor reachability state.
|
||||||
|
#[derive(
|
||||||
|
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, Serialize, Deserialize, Default,
|
||||||
|
)]
|
||||||
|
#[strum(serialize_all = "UPPERCASE", ascii_case_insensitive)]
|
||||||
|
#[serde(rename_all = "UPPERCASE")]
|
||||||
|
pub enum NeighborState {
|
||||||
|
Permanent,
|
||||||
|
Noarp,
|
||||||
|
Reachable,
|
||||||
|
Stale,
|
||||||
|
Incomplete,
|
||||||
|
Delay,
|
||||||
|
Probe,
|
||||||
|
Failed,
|
||||||
|
#[serde(other)]
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NeighborState {
|
||||||
|
/// Lowercase CLI argument form used by `ip neigh`.
|
||||||
|
pub const fn as_ip_neigh_arg(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
NeighborState::Permanent => "permanent",
|
||||||
|
NeighborState::Reachable => "reachable",
|
||||||
|
NeighborState::Stale => "stale",
|
||||||
|
NeighborState::Delay => "delay",
|
||||||
|
NeighborState::Probe => "probe",
|
||||||
|
NeighborState::Incomplete => "incomplete",
|
||||||
|
NeighborState::Noarp => "noarp",
|
||||||
|
NeighborState::None => "none",
|
||||||
|
NeighborState::Failed => "failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordering rank used when choosing the “best” state among multiple rows.
|
||||||
|
pub const fn rank(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
NeighborState::Permanent | NeighborState::Reachable => 5,
|
||||||
|
NeighborState::Stale => 4,
|
||||||
|
NeighborState::Delay | NeighborState::Probe | NeighborState::Incomplete => 3,
|
||||||
|
NeighborState::Noarp => 2,
|
||||||
|
NeighborState::None => 1,
|
||||||
|
NeighborState::Failed => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for NeighborState {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for NeighborState {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
self.rank().cmp(&other.rank())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors returned when parsing a text `ip neigh` line.
|
||||||
|
#[derive(Debug, Display, thiserror::Error)]
|
||||||
|
pub enum NeighborParseError {
|
||||||
|
IpWhere,
|
||||||
|
IpParseError(#[from] std::net::AddrParseError),
|
||||||
|
MacParseError(#[from] macaddr::ParseError),
|
||||||
|
StateWhere,
|
||||||
|
StateParseError(#[from] strum::ParseError),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one textual `ip neigh` line into a typed neighbor row.
|
||||||
|
pub fn parse_neighbor_line(s: &str) -> Result<NeighborEntry, NeighborParseError> {
|
||||||
|
let mut it = s.split_whitespace();
|
||||||
|
let ip: IpAddr = it.next().ok_or(NeighborParseError::IpWhere)?.parse()?;
|
||||||
|
|
||||||
|
let mut dev: Option<String> = None;
|
||||||
|
let mut mac: Option<MacAddr> = None;
|
||||||
|
let mut state: Option<NeighborState> = None;
|
||||||
|
let mut last_tok: Option<&str> = None;
|
||||||
|
|
||||||
|
while let Some(tok) = it.next() {
|
||||||
|
match tok {
|
||||||
|
"dev" => dev = it.next().map(str::to_string),
|
||||||
|
"lladdr" => {
|
||||||
|
mac = it.next().map(|m| m.parse()).transpose()?;
|
||||||
|
}
|
||||||
|
"nud" => {
|
||||||
|
state = it.next().map(|st| st.parse()).transpose()?;
|
||||||
|
}
|
||||||
|
other => last_tok = Some(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.is_none()
|
||||||
|
&& let Some(st) = last_tok
|
||||||
|
{
|
||||||
|
state = Some(st.parse()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(NeighborEntry {
|
||||||
|
ip,
|
||||||
|
dev,
|
||||||
|
mac,
|
||||||
|
state: state.ok_or(NeighborParseError::StateWhere)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for NeighborEntry {
|
||||||
|
type Err = NeighborParseError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
parse_neighbor_line(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::{DisplayFromStr, OneOrMany, serde_as};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use crate::model::NeighborState;
|
||||||
|
|
||||||
|
/// Legacy-compatible query shape used by HTTP and service adapters.
|
||||||
|
///
|
||||||
|
/// `name` carries free-form text selection, while `filter` carries explicit
|
||||||
|
/// machine-readable filters such as IPs, MACs, interfaces, and neighbor states.
|
||||||
|
#[derive(Debug, Default, Clone, Hash, Deserialize, Serialize)]
|
||||||
|
pub struct DeviceQuery {
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub filter: DeviceFilters,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit device filters for source- and service-level queries.
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Default, Clone, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceFilters {
|
||||||
|
#[serde_as(as = "OneOrMany<_>")]
|
||||||
|
#[serde(default)]
|
||||||
|
pub ips: Vec<IpAddr>,
|
||||||
|
#[serde_as(as = "OneOrMany<_>")]
|
||||||
|
#[serde(default)]
|
||||||
|
pub devs: Vec<String>,
|
||||||
|
#[serde_as(as = "OneOrMany<_>")]
|
||||||
|
#[serde(default)]
|
||||||
|
pub nuds: Vec<NeighborState>,
|
||||||
|
#[serde_as(as = "OneOrMany<DisplayFromStr>")]
|
||||||
|
#[serde(default)]
|
||||||
|
pub macs: Vec<MacAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path helper for routes that receive a single `{name}` segment.
|
||||||
|
#[derive(Debug, Default, Clone, Hash, Deserialize)]
|
||||||
|
pub struct NamePath {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Low-level classified input used by Linux query classification.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum QueryInput {
|
||||||
|
Ip(IpAddr),
|
||||||
|
Mac(MacAddr),
|
||||||
|
Dev(String),
|
||||||
|
Nud(NeighborState),
|
||||||
|
Name(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Higher-level typed selector used by the service layer.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Query {
|
||||||
|
Text(String),
|
||||||
|
Ip(IpAddr),
|
||||||
|
Mac(MacAddr),
|
||||||
|
Interface(String),
|
||||||
|
NeighborState(NeighborState),
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use serde_with::skip_serializing_none;
|
||||||
|
|
||||||
|
use crate::model::DeviceFilters;
|
||||||
|
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Debug, Default, Serialize)]
|
||||||
|
pub struct Status<T> {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub table: Vec<T>,
|
||||||
|
pub filters: DeviceFilters,
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::skip_serializing_none;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use crate::parse::mac;
|
||||||
|
|
||||||
|
/// Concrete Wake-on-LAN destination fields.
|
||||||
|
///
|
||||||
|
/// A fully usable target needs both an IP address and a MAC address.
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq)]
|
||||||
|
pub struct WakeTarget {
|
||||||
|
#[serde(default)]
|
||||||
|
pub ip: Option<IpAddr>,
|
||||||
|
#[serde(default, with = "mac::option_mac")]
|
||||||
|
pub mac: Option<MacAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WakeTarget {
|
||||||
|
/// Return whether this target has both fields needed to send WoL.
|
||||||
|
pub const fn is_complete(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self {
|
||||||
|
ip: Some(_),
|
||||||
|
mac: Some(_)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of waking one or more targets.
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
pub struct WakeResult {
|
||||||
|
pub result: Vec<WakeTargetResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-target wake result row.
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Debug, Serialize, Clone, Copy)]
|
||||||
|
pub struct WakeTargetResult {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub target: WakeTarget,
|
||||||
|
pub status: WakeStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of trying to wake one target.
|
||||||
|
#[derive(Debug, Serialize, Clone, Copy, Hash, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WakeStatus {
|
||||||
|
Succeed,
|
||||||
|
NonexistentAddress,
|
||||||
|
WrongSize,
|
||||||
|
Incomplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WakeTargetResult {
|
||||||
|
/// Construct an incomplete result for a target missing required fields.
|
||||||
|
pub const fn incomplete(target: WakeTarget) -> Self {
|
||||||
|
Self {
|
||||||
|
target,
|
||||||
|
status: WakeStatus::Incomplete,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
pub fn parse_numeric_ipv4(s: &str) -> Option<std::net::IpAddr> {
|
||||||
|
let s = s.trim();
|
||||||
|
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))
|
||||||
|
&& hex.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
&& let Ok(n) = u32::from_str_radix(hex, 16)
|
||||||
|
{
|
||||||
|
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
|
||||||
|
}
|
||||||
|
if s.chars().all(|c| c.is_ascii_digit())
|
||||||
|
&& let Ok(n) = s.parse::<u32>()
|
||||||
|
{
|
||||||
|
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
|
||||||
|
}
|
||||||
|
if s.len() > 1
|
||||||
|
&& s.as_bytes()[0] == b'0'
|
||||||
|
&& s.chars().all(|c| matches!(c, '0'..='7'))
|
||||||
|
&& let Ok(n) = u32::from_str_radix(s, 8)
|
||||||
|
{
|
||||||
|
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_host(input: &str) -> &str {
|
||||||
|
let mut s = input.trim();
|
||||||
|
if let Some(idx) = s.find("://") {
|
||||||
|
s = &s[idx + 3..];
|
||||||
|
} else if let Some(rest) = s.strip_prefix("//") {
|
||||||
|
s = rest;
|
||||||
|
}
|
||||||
|
if let Some((_, host)) = s.rsplit_once('@') {
|
||||||
|
s = host;
|
||||||
|
}
|
||||||
|
if let Some(host) = s.strip_prefix('[') {
|
||||||
|
if let Some(end) = host.find(']') {
|
||||||
|
s = &host[..end];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(pos) = s.find('/') {
|
||||||
|
s = &s[..pos];
|
||||||
|
}
|
||||||
|
if let Some((host, port)) = s.rsplit_once(':')
|
||||||
|
&& s.matches(':').count() == 1
|
||||||
|
&& port.chars().all(|c| c.is_ascii_digit())
|
||||||
|
{
|
||||||
|
s = host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn boolish_str(s: &str) -> bool {
|
||||||
|
let t = s.trim().to_ascii_lowercase();
|
||||||
|
if t.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|
||||||
|
|| (!matches!(t.as_str(), "0" | "false" | "no" | "off" | "n")
|
||||||
|
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod mac {
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
|
pub fn serialize<S>(m: &MacAddr, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&m.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<MacAddr, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
s.parse().map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod option_mac {
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
|
pub fn serialize<S>(m: &Option<MacAddr>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
match m {
|
||||||
|
Some(mac) => serializer.serialize_some(&mac.to_string()),
|
||||||
|
None => serializer.serialize_none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<MacAddr>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = Option::<String>::deserialize(deserializer)?;
|
||||||
|
s.map(|x| x.parse())
|
||||||
|
.transpose()
|
||||||
|
.map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vec<DisplayFromStr> ?
|
||||||
|
pub mod vec_mac {
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
|
pub fn serialize<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
let strings: Vec<String> = macs.iter().map(ToString::to_string).collect();
|
||||||
|
strings.serialize(serializer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<MacAddr>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let strings = Vec::<String>::deserialize(deserializer)?;
|
||||||
|
strings
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "wakey-linux"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
futures = "0"
|
||||||
|
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["fs", "net", "rt", "sync"] }
|
||||||
|
wakey-core = { path = "../wakey-core" }
|
||||||
|
|
||||||
|
[dependencies.lda-ipjs]
|
||||||
|
path = "../ipjs"
|
||||||
|
registry = "gitea"
|
||||||
|
version = "*"
|
||||||
|
features = ["experimental-nl"]
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use lda_ipjs::subcommands::address;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use wakey_core::{InterfaceAddr, InterfaceSummary};
|
||||||
|
|
||||||
|
/// Discover interface names from Linux without requiring `ip`.
|
||||||
|
///
|
||||||
|
/// This is the lowest-common-denominator interface listing path and is used for
|
||||||
|
/// quick existence checks and legacy string-only callers.
|
||||||
|
pub async fn list_devs() -> HashSet<String> {
|
||||||
|
fn get_dev() -> HashSet<String> {
|
||||||
|
let mut devs: HashSet<String> = HashSet::new();
|
||||||
|
if let Ok(rd) = std::fs::read_dir("/sys/class/net") {
|
||||||
|
for e in rd.flatten() {
|
||||||
|
if e.file_type()
|
||||||
|
.map(|ft| {
|
||||||
|
if ft.is_symlink() {
|
||||||
|
std::fs::metadata(e.path())
|
||||||
|
.map(|m| m.is_dir())
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
ft.is_dir()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& let Ok(name) = e.file_name().into_string()
|
||||||
|
&& name != "lo"
|
||||||
|
&& !name.is_empty()
|
||||||
|
{
|
||||||
|
devs.insert(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Ok(txt) = std::fs::read_to_string("/proc/net/dev") {
|
||||||
|
for line in txt.lines().skip(2) {
|
||||||
|
if let Some((name, _rest)) = line.split_once(':') {
|
||||||
|
let n = name.trim().to_string();
|
||||||
|
if n != "lo" && !n.is_empty() {
|
||||||
|
devs.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
devs
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(get_dev)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn devs_sorted() -> Vec<String> {
|
||||||
|
let mut v: Vec<String> = list_devs().await.into_iter().collect();
|
||||||
|
v.sort();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build condensed interface summaries from Linux address data.
|
||||||
|
///
|
||||||
|
/// On Unix this prefers the `ipjs` netlink-backed address path; elsewhere it
|
||||||
|
/// falls back to the JSON command path. The result is not a full `ip address show`
|
||||||
|
/// dump. It is a smaller projection containing the parts `wakey` currently uses:
|
||||||
|
/// interface name/index, operstate, MAC, bound addresses, and IPv4 broadcast
|
||||||
|
/// addresses for Wake-on-LAN delivery.
|
||||||
|
///
|
||||||
|
/// Loopback is intentionally excluded.
|
||||||
|
pub async fn list_interface_summaries() -> Result<Vec<InterfaceSummary>> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
let rows = address::nl::get(None)
|
||||||
|
.await
|
||||||
|
.context("rtnetlink address query failed")?;
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let rows = address::get_with_backend(address::Backend::Json, None)
|
||||||
|
.await
|
||||||
|
.context("ip -j address show failed")?;
|
||||||
|
|
||||||
|
let mut out: Vec<InterfaceSummary> = rows
|
||||||
|
.into_iter()
|
||||||
|
.filter(|row| row.ifname != "lo")
|
||||||
|
.map(|row| InterfaceSummary {
|
||||||
|
ifindex: row.ifindex,
|
||||||
|
ifname: row.ifname,
|
||||||
|
operstate: row.operstate.as_str().to_ascii_lowercase(),
|
||||||
|
mac: row.address,
|
||||||
|
addrs: row
|
||||||
|
.addr_info
|
||||||
|
.into_iter()
|
||||||
|
.map(|info| InterfaceAddr {
|
||||||
|
family: info.family.map(|family| family.as_str().to_string()),
|
||||||
|
cidr: info.cidr.to_cidr_string(),
|
||||||
|
broadcast: info.broadcast,
|
||||||
|
scope: info.scope,
|
||||||
|
label: info.label,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
out.sort_by(|a, b| a.ifname.cmp(&b.ifname));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return whether a named interface exists according to [`list_devs`].
|
||||||
|
pub async fn has_dev(name: &str) -> bool {
|
||||||
|
list_devs().await.contains(name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
mod interfaces;
|
||||||
|
mod neighbors;
|
||||||
|
mod query;
|
||||||
|
|
||||||
|
pub use interfaces::{devs_sorted, has_dev, list_devs, list_interface_summaries};
|
||||||
|
pub use neighbors::{get_ips, get_neighbors, query_status};
|
||||||
|
pub use query::classify_query;
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use futures::future::try_join_all;
|
||||||
|
use lda_ipjs::subcommands::neighbor;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use wakey_core::{DeviceQuery, NeighborEntry, NeighborState};
|
||||||
|
|
||||||
|
/// Resolve a hostname through the local resolver and return all reported IPs.
|
||||||
|
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
|
||||||
|
Ok(tokio::net::lookup_host((machine_name, 0))
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("DNS resolve failed for {machine_name}"))?
|
||||||
|
.map(|c| c.ip()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query Linux neighbor data and project it into `wakey-core` neighbor rows.
|
||||||
|
///
|
||||||
|
/// `machine_names` are resolved first and intersected with any explicit `ips`
|
||||||
|
/// filter. On Unix this prefers the netlink-backed `ipjs` path; elsewhere it
|
||||||
|
/// falls back to the JSON command backend.
|
||||||
|
pub async fn get_neighbors(
|
||||||
|
machine_names: &[impl AsRef<str>],
|
||||||
|
ips: &[IpAddr],
|
||||||
|
devs: &[impl AsRef<str>],
|
||||||
|
state: &[NeighborState],
|
||||||
|
macs: &[macaddr::MacAddr],
|
||||||
|
) -> Result<Vec<NeighborEntry>> {
|
||||||
|
let resolved_ips: HashSet<IpAddr> = if !machine_names.is_empty() {
|
||||||
|
try_join_all(machine_names.iter().map(|n| get_ips(n.as_ref())))
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
HashSet::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let ip_filter: Vec<IpAddr> = if ips.is_empty() && resolved_ips.is_empty() {
|
||||||
|
vec![]
|
||||||
|
} else if ips.is_empty() {
|
||||||
|
resolved_ips.into_iter().collect()
|
||||||
|
} else if resolved_ips.is_empty() {
|
||||||
|
ips.iter().map(|ip| ip.to_canonical()).collect()
|
||||||
|
} else {
|
||||||
|
ips.iter()
|
||||||
|
.map(|ip| ip.to_canonical())
|
||||||
|
.filter(|ip| resolved_ips.contains(ip))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let nud_filter: Vec<neighbor::NUDState> = state.iter().copied().map(to_ipjs_state).collect();
|
||||||
|
let dev_strs: Vec<&str> = devs.iter().map(AsRef::as_ref).collect();
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let results = neighbor::nl::get(&ip_filter, &dev_strs, &nud_filter, macs)
|
||||||
|
.await
|
||||||
|
.context("rtnetlink failed")?
|
||||||
|
.into_iter()
|
||||||
|
.map(map_neighbor_item)
|
||||||
|
.collect();
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let mut results = Vec::new();
|
||||||
|
if ip_filter.is_empty() {
|
||||||
|
let dev = dev_strs.first().copied();
|
||||||
|
results = neighbor::get_with_backend(neighbor::Backend::Json, None, dev, &nud_filter)
|
||||||
|
.await
|
||||||
|
.context("ip -j neigh failed")?
|
||||||
|
.into_iter()
|
||||||
|
.map(map_neighbor_item)
|
||||||
|
.collect();
|
||||||
|
} else {
|
||||||
|
for ip in &ip_filter {
|
||||||
|
let dev = dev_strs.first().copied();
|
||||||
|
let mut rows = neighbor::get_with_backend(
|
||||||
|
neighbor::Backend::Json,
|
||||||
|
Some(*ip),
|
||||||
|
dev,
|
||||||
|
&nud_filter,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("ip -j neigh failed for {ip}"))?
|
||||||
|
.into_iter()
|
||||||
|
.map(map_neighbor_item)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
results.append(&mut rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dev_strs.is_empty() {
|
||||||
|
results.retain(|row| row.dev.as_deref().is_some_and(|d| dev_strs.contains(&d)));
|
||||||
|
}
|
||||||
|
if !macs.is_empty() {
|
||||||
|
let mac_set: HashSet<macaddr::MacAddr> = macs.iter().copied().collect();
|
||||||
|
results.retain(|row| row.mac.is_some_and(|m| mac_set.contains(&m)));
|
||||||
|
}
|
||||||
|
if !ip_filter.is_empty() {
|
||||||
|
let ip_set: HashSet<IpAddr> = ip_filter.iter().copied().collect();
|
||||||
|
results.retain(|row| ip_set.contains(&row.ip));
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience wrapper around [`get_neighbors`] using the legacy `DeviceQuery`.
|
||||||
|
pub async fn query_status(query: &DeviceQuery) -> Result<Vec<NeighborEntry>> {
|
||||||
|
get_neighbors(
|
||||||
|
query.name.as_slice(),
|
||||||
|
&query.filter.ips,
|
||||||
|
&query.filter.devs,
|
||||||
|
&query.filter.nuds,
|
||||||
|
&query.filter.macs,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_ipjs_state(value: NeighborState) -> lda_ipjs::subcommands::neighbor::NUDState {
|
||||||
|
match value {
|
||||||
|
NeighborState::Permanent => lda_ipjs::subcommands::neighbor::NUDState::Permanent,
|
||||||
|
NeighborState::Noarp => lda_ipjs::subcommands::neighbor::NUDState::Noarp,
|
||||||
|
NeighborState::Reachable => lda_ipjs::subcommands::neighbor::NUDState::Reachable,
|
||||||
|
NeighborState::Stale => lda_ipjs::subcommands::neighbor::NUDState::Stale,
|
||||||
|
NeighborState::None => lda_ipjs::subcommands::neighbor::NUDState::None,
|
||||||
|
NeighborState::Incomplete => lda_ipjs::subcommands::neighbor::NUDState::Incomplete,
|
||||||
|
NeighborState::Delay => lda_ipjs::subcommands::neighbor::NUDState::Delay,
|
||||||
|
NeighborState::Probe => lda_ipjs::subcommands::neighbor::NUDState::Probe,
|
||||||
|
NeighborState::Failed => lda_ipjs::subcommands::neighbor::NUDState::Failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_ipjs_state(value: lda_ipjs::subcommands::neighbor::NUDState) -> NeighborState {
|
||||||
|
match value {
|
||||||
|
lda_ipjs::subcommands::neighbor::NUDState::Permanent => NeighborState::Permanent,
|
||||||
|
lda_ipjs::subcommands::neighbor::NUDState::Noarp => NeighborState::Noarp,
|
||||||
|
lda_ipjs::subcommands::neighbor::NUDState::Reachable => NeighborState::Reachable,
|
||||||
|
lda_ipjs::subcommands::neighbor::NUDState::Stale => NeighborState::Stale,
|
||||||
|
lda_ipjs::subcommands::neighbor::NUDState::None => NeighborState::None,
|
||||||
|
lda_ipjs::subcommands::neighbor::NUDState::Incomplete => NeighborState::Incomplete,
|
||||||
|
lda_ipjs::subcommands::neighbor::NUDState::Delay => NeighborState::Delay,
|
||||||
|
lda_ipjs::subcommands::neighbor::NUDState::Probe => NeighborState::Probe,
|
||||||
|
lda_ipjs::subcommands::neighbor::NUDState::Failed => NeighborState::Failed,
|
||||||
|
lda_ipjs::subcommands::neighbor::NUDState::Other(_) => NeighborState::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_neighbor_item(
|
||||||
|
lda_ipjs::subcommands::neighbor::NeighborItem {
|
||||||
|
ip,
|
||||||
|
dev,
|
||||||
|
mac,
|
||||||
|
state,
|
||||||
|
}: lda_ipjs::subcommands::neighbor::NeighborItem,
|
||||||
|
) -> NeighborEntry {
|
||||||
|
NeighborEntry {
|
||||||
|
ip,
|
||||||
|
dev,
|
||||||
|
mac,
|
||||||
|
state: state
|
||||||
|
.into_iter()
|
||||||
|
.map(from_ipjs_state)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(NeighborState::None),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use wakey_core::{NeighborState, QueryInput, parse};
|
||||||
|
|
||||||
|
use crate::devices::interfaces::has_dev;
|
||||||
|
|
||||||
|
/// Classify one free-form input string into the most specific query variant.
|
||||||
|
///
|
||||||
|
/// The current precedence is:
|
||||||
|
/// IP address, MAC address, neighbor state, interface name, then plain text.
|
||||||
|
pub async fn classify_query(q: String) -> QueryInput {
|
||||||
|
let s = parse::extract_host(&q);
|
||||||
|
if let Some(ip) = parse::parse_numeric_ipv4(s).or_else(|| s.parse::<IpAddr>().ok()) {
|
||||||
|
return QueryInput::Ip(ip);
|
||||||
|
}
|
||||||
|
if let Ok(mac) = s.parse::<macaddr::MacAddr>() {
|
||||||
|
return QueryInput::Mac(mac);
|
||||||
|
}
|
||||||
|
if let Ok(state) = s.parse::<NeighborState>() {
|
||||||
|
return QueryInput::Nud(state);
|
||||||
|
}
|
||||||
|
if has_dev(s).await {
|
||||||
|
return QueryInput::Dev(s.to_string());
|
||||||
|
}
|
||||||
|
QueryInput::Name(s.to_string())
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
/// MAC->name cache location (ephemeral)
|
use std::io::{self, ErrorKind};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use wakey_core::{DhcpLease, DhcpLeaseWithState};
|
||||||
|
|
||||||
const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
|
const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
|
||||||
|
|
||||||
/// Load MAC->name cache from disk
|
/// Load the MAC-to-name cache used to preserve useful names across lease churn.
|
||||||
pub(crate) async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>>
|
pub async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>> {
|
||||||
{
|
|
||||||
match tokio::fs::read_to_string(MAC_NAME_CACHE).await {
|
match tokio::fs::read_to_string(MAC_NAME_CACHE).await {
|
||||||
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
||||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
|
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
|
||||||
@@ -11,15 +14,39 @@ pub(crate) async fn load_mac_name_cache() -> io::Result<std::collections::BTreeM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save MAC->name cache to disk
|
/// Persist the MAC-to-name cache back to disk.
|
||||||
async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -> io::Result<()> {
|
async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -> io::Result<()> {
|
||||||
let s = serde_json::to_string(map).map_err(io::Error::other)?;
|
let s = serde_json::to_string(map).map_err(io::Error::other)?;
|
||||||
let _ = tokio::fs::write(MAC_NAME_CACHE, s).await;
|
let _ = tokio::fs::write(MAC_NAME_CACHE, s).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read all leases, filling names from MAC->name cache if missing
|
/// Parse one `dnsmasq`-style DHCP lease line.
|
||||||
pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLeaseLine>> {
|
pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLease> {
|
||||||
|
let mut c = line.split_whitespace();
|
||||||
|
let expires_epoch: u64 = c.next()?.parse().ok()?;
|
||||||
|
let mac = c.next()?.parse().ok()?;
|
||||||
|
let ip = c.next()?.parse().ok()?;
|
||||||
|
let name = c.next().filter(|c| *c != "*").map(str::to_string);
|
||||||
|
Some(DhcpLease {
|
||||||
|
expires_epoch,
|
||||||
|
ip,
|
||||||
|
mac,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read raw DHCP leases from `/tmp/dhcp.leases`.
|
||||||
|
pub async fn read_dhcp_leases() -> io::Result<Vec<DhcpLease>> {
|
||||||
|
match tokio::fs::read_to_string("/tmp/dhcp.leases").await {
|
||||||
|
Ok(file) => Ok(file.lines().filter_map(parse_dhcp_lease_line).collect()),
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read DHCP leases and fill missing names from the MAC-name cache.
|
||||||
|
pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLease>> {
|
||||||
let leases = read_dhcp_leases().await?;
|
let leases = read_dhcp_leases().await?;
|
||||||
let mut cache = load_mac_name_cache().await.unwrap_or_default();
|
let mut cache = load_mac_name_cache().await.unwrap_or_default();
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
@@ -27,7 +54,6 @@ pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLeaseLine>> {
|
|||||||
for mut l in leases {
|
for mut l in leases {
|
||||||
let mac_s = l.mac.to_string();
|
let mac_s = l.mac.to_string();
|
||||||
if let Some(ref name) = l.name {
|
if let Some(ref name) = l.name {
|
||||||
// if no name in cache file or it changed
|
|
||||||
if cache.get(&mac_s).map(|v| v != name).unwrap_or(true) {
|
if cache.get(&mac_s).map(|v| v != name).unwrap_or(true) {
|
||||||
cache.insert(mac_s, name.clone());
|
cache.insert(mac_s, name.clone());
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -42,43 +68,32 @@ pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLeaseLine>> {
|
|||||||
}
|
}
|
||||||
Ok(leases_with_names)
|
Ok(leases_with_names)
|
||||||
}
|
}
|
||||||
use crate::utils::parse::mac;
|
|
||||||
use macaddr::MacAddr;
|
|
||||||
use std::io::{self, ErrorKind};
|
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
/// A single line from /tmp/dhcp.leases
|
/// Enrich DHCP leases with the best currently known neighbor state per IP.
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
|
||||||
pub struct DhcpLeaseLine {
|
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();
|
||||||
/// Epoch seconds when the lease expires
|
let mut map: std::collections::HashMap<IpAddr, wakey_core::NeighborState> =
|
||||||
pub expires_epoch: u64,
|
std::collections::HashMap::new();
|
||||||
pub ip: IpAddr,
|
if let Ok(rows) =
|
||||||
#[serde(with = "mac")]
|
crate::devices::get_neighbors(&[] as &[&str], &ips, &[] as &[&str], &[], &[]).await
|
||||||
pub mac: MacAddr,
|
{
|
||||||
pub name: Option<String>,
|
for row in rows {
|
||||||
}
|
let state = row.state;
|
||||||
|
let r = state.rank();
|
||||||
/// Parse one line of /tmp/dhcp.leases
|
map.entry(row.ip)
|
||||||
pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLeaseLine> {
|
.and_modify(|e| {
|
||||||
let mut c = line.split_whitespace();
|
if r > e.rank() {
|
||||||
let expires_epoch: u64 = c.next()?.parse().ok()?;
|
*e = state
|
||||||
let mac = c.next()?.parse().ok()?;
|
}
|
||||||
let ip = c.next()?.parse().ok()?;
|
})
|
||||||
let name = c.next().filter(|c| *c != "*").map(str::to_string);
|
.or_insert(state);
|
||||||
// ignore any remaining columns (e.g., client-id)
|
}
|
||||||
Some(DhcpLeaseLine {
|
|
||||||
expires_epoch,
|
|
||||||
ip,
|
|
||||||
mac,
|
|
||||||
name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read all leases from /tmp/dhcp.leases (simple and fast)
|
|
||||||
pub async fn read_dhcp_leases() -> io::Result<Vec<DhcpLeaseLine>> {
|
|
||||||
match tokio::fs::read_to_string("/tmp/dhcp.leases").await {
|
|
||||||
Ok(file) => Ok(file.lines().filter_map(parse_dhcp_lease_line).collect()),
|
|
||||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
}
|
||||||
|
leases
|
||||||
|
.into_iter()
|
||||||
|
.map(|lease_line| DhcpLeaseWithState {
|
||||||
|
nud_state: map.get(&lease_line.ip).copied(),
|
||||||
|
lease_line,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod devices;
|
||||||
|
pub mod dhcp;
|
||||||
|
pub mod wake;
|
||||||
|
|
||||||
|
pub use devices::*;
|
||||||
|
pub use dhcp::*;
|
||||||
|
pub use wake::*;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use std::{io, net::IpAddr};
|
||||||
|
|
||||||
|
use futures::TryFutureExt;
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
use wakey_core::{WakeStatus, WakeTarget, WakeTargetResult};
|
||||||
|
|
||||||
|
/// Wake target with the minimum fields needed to send a magic packet.
|
||||||
|
#[derive(Debug, Clone, Copy, Hash)]
|
||||||
|
pub struct CompleteWakeTarget {
|
||||||
|
pub ip: IpAddr,
|
||||||
|
pub mac: MacAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<WakeTarget> for CompleteWakeTarget {
|
||||||
|
type Error = ();
|
||||||
|
|
||||||
|
fn try_from(value: WakeTarget) -> Result<Self, Self::Error> {
|
||||||
|
if let WakeTarget {
|
||||||
|
ip: Some(ip),
|
||||||
|
mac: Some(mac),
|
||||||
|
} = value
|
||||||
|
{
|
||||||
|
Ok(Self { ip, mac })
|
||||||
|
} else {
|
||||||
|
Err(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send one Wake-on-LAN magic packet to a complete target.
|
||||||
|
pub async fn wake_one(sock: &UdpSocket, t: CompleteWakeTarget) -> WakeTargetResult {
|
||||||
|
let mac = t.mac;
|
||||||
|
let mb = mac.as_bytes();
|
||||||
|
let mut pac = [0; 6 + 6 * 16];
|
||||||
|
pac[..6].fill(0xff);
|
||||||
|
for i in 1..=16 {
|
||||||
|
pac[i * 6..(i + 1) * 6].copy_from_slice(mb);
|
||||||
|
}
|
||||||
|
match sock.send_to(&pac, (t.ip, 9)).await {
|
||||||
|
Ok(n) if n == pac.len() => WakeTargetResult {
|
||||||
|
target: WakeTarget {
|
||||||
|
ip: Some(t.ip),
|
||||||
|
mac: Some(t.mac),
|
||||||
|
},
|
||||||
|
status: WakeStatus::Succeed,
|
||||||
|
},
|
||||||
|
Ok(_) => WakeTargetResult {
|
||||||
|
target: WakeTarget {
|
||||||
|
ip: Some(t.ip),
|
||||||
|
mac: Some(t.mac),
|
||||||
|
},
|
||||||
|
status: WakeStatus::WrongSize,
|
||||||
|
},
|
||||||
|
Err(_) => WakeTargetResult {
|
||||||
|
target: WakeTarget {
|
||||||
|
ip: Some(t.ip),
|
||||||
|
mac: Some(t.mac),
|
||||||
|
},
|
||||||
|
status: WakeStatus::NonexistentAddress,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send Wake-on-LAN packets for many targets using one UDP socket.
|
||||||
|
///
|
||||||
|
/// Incomplete targets are not rejected with an error; they are returned as
|
||||||
|
/// `WakeStatus::Incomplete` result rows.
|
||||||
|
pub async fn wake_many(
|
||||||
|
targets: impl IntoIterator<Item = WakeTarget>,
|
||||||
|
) -> io::Result<Vec<WakeTargetResult>> {
|
||||||
|
let sock = UdpSocket::bind("[::]:0")
|
||||||
|
.or_else(|_| UdpSocket::bind(":0"))
|
||||||
|
.await?;
|
||||||
|
sock.set_broadcast(true)?;
|
||||||
|
|
||||||
|
let iter = targets
|
||||||
|
.into_iter()
|
||||||
|
.map(async |target| match CompleteWakeTarget::try_from(target) {
|
||||||
|
Ok(target) => wake_one(&sock, target).await,
|
||||||
|
Err(()) => WakeTargetResult::incomplete(target),
|
||||||
|
});
|
||||||
|
Ok(futures::future::join_all(iter).await)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user