11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Rust build output
|
||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# editor / OS
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
568
Cargo.lock
generated
Normal file
568
Cargo.lock
generated
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 3
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "avni"
|
||||||
|
version = "0.2.0"
|
||||||
|
dependencies = [
|
||||||
|
"crossterm",
|
||||||
|
"ratatui",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cassowary"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "castaway"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compact_str"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
|
||||||
|
dependencies = [
|
||||||
|
"castaway",
|
||||||
|
"cfg-if",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"static_assertions",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm_winapi"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.186"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lock_api"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||||
|
dependencies = [
|
||||||
|
"scopeguard",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "0.8.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot_core"
|
||||||
|
version = "0.9.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"redox_syscall",
|
||||||
|
"smallvec",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ratatui"
|
||||||
|
version = "0.26.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cassowary",
|
||||||
|
"compact_str",
|
||||||
|
"crossterm",
|
||||||
|
"itertools 0.12.1",
|
||||||
|
"lru",
|
||||||
|
"paste",
|
||||||
|
"stability",
|
||||||
|
"strum",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-truncate",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.150"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-mio"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"signal-hook",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stability"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.26.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.118"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-truncate"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||||
|
dependencies = [
|
||||||
|
"itertools 0.13.0",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.48.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "avni"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "avni"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.26"
|
||||||
|
crossterm = "0.27"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 2
|
||||||
|
codegen-units = 16
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Jagadeesh (AVNI Cloud)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
201
README.md
Normal file
201
README.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# avni — a single-binary control center for your VPS
|
||||||
|
|
||||||
|
`avni` is a fast, dependency-light **terminal dashboard (TUI) + CLI** for operating a
|
||||||
|
self-hosted Linux server: see your Docker apps, domains, bandwidth, SSH sessions and
|
||||||
|
brute-force attackers at a glance — and act on them (start/stop services, ban/blacklist
|
||||||
|
IPs, manage users) without leaving the terminal.
|
||||||
|
|
||||||
|
It shells out to the tools already on the box (`docker`, `fail2ban-client`, `ipset`,
|
||||||
|
`vnstat`, `who`/`last`, `getent`, …) — **no daemon, no database, no agent**. One Rust
|
||||||
|
binary, ~2% CPU, microsecond redraws.
|
||||||
|
|
||||||
|
> Built for a small multi-app VPS behind [Traefik](https://traefik.io/). It auto-discovers
|
||||||
|
> the apps Traefik routes, so the dashboard never goes stale as you add or remove services.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌ AVNI CLOUD · control center ─────────────────────────────────────────────────────────┐
|
||||||
|
│ 1.Overview · 2.Services · 3.Domains · 4.Traffic · 5.Security · 6.SSH · 7.Users · 8.Set…│
|
||||||
|
└────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌ Applications (9/9 enabled) — s enable · x disable · R restart · l logs · ↵ details ─────┐
|
||||||
|
│ APP STATE SUBDOMAIN PORT HOST PORTS │
|
||||||
|
│ » ● gitea enabled git.example.com 3000 127.0.0.1:3000->3000 │
|
||||||
|
│ ● n8n enabled n8n.example.com 5678 127.0.0.1:5678->5678 │
|
||||||
|
│ ● uptime-kuma enabled uptime.example.com 3001 127.0.0.1:3001->3001 │
|
||||||
|
│ ○ analytics disabled stats.example.com 3000 - │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
s enable x disable R restart l logs ↵ details 1-9/⇄ tabs ↑↓ move r refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
- [Features](#features)
|
||||||
|
- [Requirements](#requirements)
|
||||||
|
- [Install](#install)
|
||||||
|
- [Using the TUI](#using-the-tui)
|
||||||
|
- [CLI](#cli-scriptable)
|
||||||
|
- [Access control — who can run `avni`](#access-control--who-can-run-avni)
|
||||||
|
- [How it works](#how-it-works)
|
||||||
|
- [Data & file locations](#data--file-locations)
|
||||||
|
- [Security notes](#security-notes)
|
||||||
|
- [Build from source](#build-from-source)
|
||||||
|
- [Python fallback](#python-fallback)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
A tabbed dashboard (switch with `1`–`9`, `Tab`, or `←/→`):
|
||||||
|
|
||||||
|
| Tab | What it shows / does |
|
||||||
|
|-----|----------------------|
|
||||||
|
| **Overview** | host, IP, uptime, load/memory/disk gauges, service & attacker summary, dual clock (server + your local time) |
|
||||||
|
| **Services** | every Docker container with status + the domain Traefik routes to it; `s`/`x`/`R` start/stop/restart, `l` logs |
|
||||||
|
| **Domains** | every `Host()` route → backend; `Enter` checks live HTTP status + TLS cert expiry |
|
||||||
|
| **Traffic** | `vnstat` bandwidth bar chart, 24h / 7d / 30d windows (pins the real uplink interface) |
|
||||||
|
| **Security** | a persistent **IP registry** of everyone who has touched the box (country, attempts, logins, status). Temp-ban (fail2ban), permanent firewall **blacklist** (ipset+iptables), and a **whitelist** that can never be banned |
|
||||||
|
| **SSH** | live sessions (kick / kick+blacklist) + the effective `sshd` policy |
|
||||||
|
| **Users** | list/create/delete Linux users, grant/revoke sudo, lock/unlock, **set login password**, and a full per-user **activity** view (system metadata only) |
|
||||||
|
| **Settings** | delegate the tool to other admins (see [access control](#access-control--who-can-run-avni)) |
|
||||||
|
| **Apps** | the user-facing apps Traefik routes — **enable/disable**, ports, subdomain, live HTTPS + cert details |
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
- **Auto-discovery** — apps and domains come straight from container labels; nothing to configure.
|
||||||
|
- **Persistent attacker registry** with **incremental log parsing** (reads only newly-appended bytes of `auth.log`, survives rotation).
|
||||||
|
- **Optimistic UI** — actions reflect instantly, then reconcile with the real system state.
|
||||||
|
- **Dual time** — every timestamp shows a second timezone beside it (configured for IST in the source; see [Build](#build-from-source) to change).
|
||||||
|
- **Scriptable** — every view is also a one-shot CLI subcommand.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux, run as **root** (or as a delegated `avni`-group member — it self-elevates via sudo).
|
||||||
|
- A recent **Rust** toolchain (rustup stable; ratatui 0.26 needs a newer rustc than some distros ship).
|
||||||
|
- Expected on the box (features degrade gracefully if missing):
|
||||||
|
`docker`, `fail2ban-client`, `ipset` + `iptables`, `vnstat`, `geoiplookup` (geoip-bin),
|
||||||
|
`whois`, plus coreutils (`who`, `last`, `lastb`, `getent`, `passwd`, `chpasswd`).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.example.com/you/vps-tool-avni.git
|
||||||
|
cd vps-tool-avni
|
||||||
|
sudo bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` builds the release binary and installs it to `/usr/local/bin/avni`, installs the
|
||||||
|
firewall helper to `/usr/local/bin/avni-firewall.sh`, creates the `avni` group, and writes a
|
||||||
|
sudoers drop-in so delegated admins can launch it. Re-run it after pulling changes.
|
||||||
|
|
||||||
|
Then just:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
avni # full-screen dashboard
|
||||||
|
avni status # one-shot CLI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using the TUI
|
||||||
|
|
||||||
|
Global keys: `1`–`9` / `Tab` / `←→` switch tabs · `↑↓` (or `j`/`k`) move · `r` refresh · `q` quit.
|
||||||
|
|
||||||
|
Per-tab actions are always shown in the footer. The most useful:
|
||||||
|
|
||||||
|
- **Services / Apps** — `s` start/enable · `x` stop/disable · `R` restart · `l` logs · `Enter` details
|
||||||
|
- **Security** — `[` `]` switch sub-tab · `a` add blacklist · `A` add whitelist · `b` ban · `B` blacklist selected · `W` whitelist selected · `u` undo · `w` whois · `Enter` IP intel
|
||||||
|
- **SSH** — `k` kick a session · `K` kick + blacklist its IP
|
||||||
|
- **Users** — `N` new user · `P` set login/SSH password (masked) · `G`/`g` grant/revoke sudo · `L`/`U` lock/unlock · `D` delete (keeps `/home`) · `Enter` activity
|
||||||
|
- **Settings** — `g` grant the tool · `x` revoke
|
||||||
|
|
||||||
|
Full reference: **[docs/guide.md](docs/guide.md)**.
|
||||||
|
|
||||||
|
## CLI (scriptable)
|
||||||
|
|
||||||
|
Every subcommand prints the same data the TUI shows:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
avni status # host + counts
|
||||||
|
avni services # all containers
|
||||||
|
avni apps # user-facing apps: state · subdomain · port
|
||||||
|
avni ips [attackers|banned|new|all] # the IP registry
|
||||||
|
avni attackers [N] # top N attackers with country
|
||||||
|
avni ban <ip> | unban <ip>
|
||||||
|
avni blacklist [add <ip|cidr|domain>]
|
||||||
|
avni whitelist [add|del <ip>]
|
||||||
|
avni geo <ip> | ssh | users
|
||||||
|
avni adduser <user> [password] # create account (-m home, bash)
|
||||||
|
avni passwd <user> <password> # set a user's Linux login / SSH password
|
||||||
|
avni sudo <user> | unsudo <user> # grant / revoke sudo
|
||||||
|
avni deluser <user> # delete account, KEEP /home
|
||||||
|
avni activity <user> # logins, failed logins, sudo cmds, processes
|
||||||
|
avni access | grant <user> | revoke <user> # tool delegation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access control — who can run `avni`
|
||||||
|
|
||||||
|
The tool is **root-only** by default. To let a teammate use it:
|
||||||
|
|
||||||
|
1. They must already be an **admin (sudo)** — `avni sudo <user>` (Users tab `G`).
|
||||||
|
2. Grant the tool — `avni grant <user>` (Settings tab `g`). This adds them to the `avni`
|
||||||
|
group; the sudoers drop-in lets the binary **auto-elevate via sudo** when they run it.
|
||||||
|
|
||||||
|
Anyone not granted gets a polite "not authorised" message. Root can never be revoked.
|
||||||
|
The single binary at `/usr/local/bin/avni` is a real root-owned file (mode 0755), so a
|
||||||
|
delegated user can exec it but cannot modify it.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- **Background gather** — a worker thread fans out the independent fetches (docker, auth-log
|
||||||
|
registry, sessions, users, vnstat) across threads, so a full refresh ≈ the slowest single
|
||||||
|
part (~2 s) instead of the sum. The UI never blocks; a loading splash shows on first start.
|
||||||
|
- **Incremental log parsing** — stores a byte offset per log inode and reads only the new tail
|
||||||
|
of `auth.log` on each refresh (not the whole multi-MB file).
|
||||||
|
- **Auto-discovery** — services, domains and apps are parsed from `docker ps` + Traefik
|
||||||
|
`Host()` labels in a single call. Nothing is hard-coded; add a container and it appears.
|
||||||
|
- **Traffic** pins the **default-route interface** (a busy box can have dozens of docker
|
||||||
|
veth/bridge interfaces; `vnstat`'s `interfaces[0]` is often a zero-traffic bridge).
|
||||||
|
|
||||||
|
## Data & file locations
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `/usr/local/bin/avni` | the binary |
|
||||||
|
| `/usr/local/bin/avni-firewall.sh` | blacklist/whitelist engine (ipset + iptables) |
|
||||||
|
| `/var/lib/avni/ips.json` | persistent IP registry (+ per-log byte offsets) |
|
||||||
|
| `/etc/avni/whitelist.txt`, `/etc/avni/blacklist.save` | firewall state |
|
||||||
|
| `/etc/fail2ban/jail.d/avni-whitelist.conf` | auto-generated fail2ban `ignoreip` |
|
||||||
|
| `/etc/sudoers.d/avni` | NOPASSWD drop-in for the `avni` group |
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- **No secrets are stored in this repo or the binary.** `avni` reads live system state at
|
||||||
|
runtime; it never embeds passwords, IPs or hostnames. (The examples in the code are
|
||||||
|
[RFC 5737](https://datatracker.ietf.org/doc/html/rfc5737) documentation addresses.)
|
||||||
|
- **Whitelist your own IPs first.** A firewall guard and fail2ban `ignoreip` then make it
|
||||||
|
impossible to accidentally lock yourself out under a brute-force burst; an anti-lockout
|
||||||
|
check also refuses to blacklist your current SSH client IP.
|
||||||
|
- **Docker-published ports bypass host UFW** — the firewall engine adds rules to the
|
||||||
|
`DOCKER-USER` chain too, so a blacklist actually blocks container-exposed ports.
|
||||||
|
- The per-user **activity** view uses only system metadata (logins, sudo audit, `ps`) and
|
||||||
|
**never reads anyone's home directory or shell history** — safe on a multi-tenant box.
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release # at the repo root → target/release/avni
|
||||||
|
```
|
||||||
|
|
||||||
|
Toolchain: rustup **stable**. Dependencies are just `ratatui`, `crossterm`, `serde`,
|
||||||
|
`serde_json` (see `Cargo.toml`).
|
||||||
|
|
||||||
|
**Changing the second timezone:** the dual-clock offset is computed in `ist_delta_min()` in
|
||||||
|
`src/main.rs` (currently targets IST, +05:30). Adjust the `330` (minutes) and the
|
||||||
|
`TZ=Asia/Kolkata` strings to your zone.
|
||||||
|
|
||||||
|
## Python fallback
|
||||||
|
|
||||||
|
`avni.py` is an earlier pure-Python (curses) implementation kept as a zero-build fallback.
|
||||||
|
`install.sh` installs it as `/usr/local/bin/avni-py`. The Rust binary is the maintained one.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE) © 2026 Jagadeesh (AVNI Cloud).
|
||||||
75
avni-firewall.sh
Executable file
75
avni-firewall.sh
Executable file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# AVNI firewall: persistent blacklist (ipset+iptables, host + Docker) and a
|
||||||
|
# WHITELIST that can never be banned/blacklisted (fail2ban ignoreip + guard).
|
||||||
|
set -uo pipefail
|
||||||
|
SET=avni_blacklist
|
||||||
|
SAVE=/etc/avni/blacklist.save
|
||||||
|
WL=/etc/avni/whitelist.txt
|
||||||
|
F2B_CONF=/etc/fail2ban/jail.d/avni-whitelist.conf
|
||||||
|
mkdir -p /etc/avni; touch "$WL"
|
||||||
|
|
||||||
|
ensure_set(){ ipset list -n 2>/dev/null | grep -qx "$SET" || \
|
||||||
|
ipset create "$SET" hash:net family inet hashsize 1024 maxelem 65536; }
|
||||||
|
ensure_rules(){
|
||||||
|
iptables -C INPUT -m set --match-set "$SET" src -j DROP 2>/dev/null || \
|
||||||
|
iptables -I INPUT 1 -m set --match-set "$SET" src -j DROP
|
||||||
|
if iptables -L DOCKER-USER -n >/dev/null 2>&1; then
|
||||||
|
iptables -C DOCKER-USER -m set --match-set "$SET" src -j DROP 2>/dev/null || \
|
||||||
|
iptables -I DOCKER-USER 1 -m set --match-set "$SET" src -j DROP
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
save(){ ipset save "$SET" > "$SAVE" 2>/dev/null; }
|
||||||
|
my_ip(){ echo "${SSH_CLIENT%% *}"; }
|
||||||
|
is_wl(){ grep -qxF "$1" "$WL" 2>/dev/null; }
|
||||||
|
jails(){ fail2ban-client status 2>/dev/null | sed -n 's/.*Jail list:[[:space:]]*//p' | tr ',' ' '; }
|
||||||
|
|
||||||
|
gen_f2b(){ # write a persistent ignoreip drop-in from the whitelist + reload
|
||||||
|
local ips; ips=$(tr '\n' ' ' < "$WL")
|
||||||
|
{ echo "# AUTO-GENERATED by avni-firewall.sh — do not edit; use: avni whitelist add/del"
|
||||||
|
echo "[DEFAULT]"
|
||||||
|
echo "ignoreip = 127.0.0.1/8 ::1 $ips"; } > "$F2B_CONF"
|
||||||
|
fail2ban-client reload >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(){
|
||||||
|
if [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?$ ]]; then echo "$1"; return; fi
|
||||||
|
getent ahostsv4 "$1" 2>/dev/null | awk '{print $1}' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
init) ensure_set; ensure_rules; save ;;
|
||||||
|
restore) if [ -f "$SAVE" ]; then ipset restore -! < "$SAVE" 2>/dev/null; else ensure_set; fi; ensure_rules; gen_f2b ;;
|
||||||
|
add)
|
||||||
|
ensure_set; ensure_rules
|
||||||
|
for ip in $(resolve "$2"); do
|
||||||
|
if [ "$ip" = "$(my_ip)" ]; then echo "REFUSED: $ip is YOUR current SSH IP"; continue; fi
|
||||||
|
if is_wl "$ip"; then echo "REFUSED: $ip is WHITELISTED (remove from whitelist first)"; continue; fi
|
||||||
|
ipset add -! "$SET" "$ip" && echo "blacklisted $ip"
|
||||||
|
done; save ;;
|
||||||
|
del)
|
||||||
|
for ip in $(resolve "$2"); do ipset del "$SET" "$ip" 2>/dev/null && echo "removed $ip" || echo "$ip not listed"; done; save ;;
|
||||||
|
list) ipset list "$SET" 2>/dev/null | sed -n '/Members:/,$p' | tail -n +2 ;;
|
||||||
|
count) ipset list "$SET" 2>/dev/null | sed -n '/Members:/,$p' | tail -n +2 | grep -c . ;;
|
||||||
|
whitelist)
|
||||||
|
case "${2:-list}" in
|
||||||
|
add)
|
||||||
|
for ip in $(resolve "$3"); do
|
||||||
|
grep -qxF "$ip" "$WL" || echo "$ip" >> "$WL"
|
||||||
|
ipset del "$SET" "$ip" 2>/dev/null # un-blacklist if present
|
||||||
|
for j in $(jails); do
|
||||||
|
fail2ban-client set "$j" addignoreip "$ip" >/dev/null 2>&1
|
||||||
|
fail2ban-client set "$j" unbanip "$ip" >/dev/null 2>&1
|
||||||
|
done
|
||||||
|
echo "whitelisted + unbanned $ip (can never be banned/blacklisted)"
|
||||||
|
done
|
||||||
|
sort -u "$WL" -o "$WL"; gen_f2b; save ;;
|
||||||
|
del)
|
||||||
|
for ip in $(resolve "$3"); do
|
||||||
|
grep -vxF "$ip" "$WL" > "$WL.tmp" && mv "$WL.tmp" "$WL"
|
||||||
|
for j in $(jails); do fail2ban-client set "$j" delignoreip "$ip" >/dev/null 2>&1; done
|
||||||
|
echo "removed $ip from whitelist"
|
||||||
|
done; gen_f2b ;;
|
||||||
|
list|*) [ -s "$WL" ] && cat "$WL" || echo "(whitelist empty)" ;;
|
||||||
|
esac ;;
|
||||||
|
*) echo "usage: $0 {init|restore|add <ip|cidr|domain>|del <ip>|list|count|whitelist {add|del|list} <ip>}"; exit 1 ;;
|
||||||
|
esac
|
||||||
715
avni.py
Executable file
715
avni.py
Executable file
@@ -0,0 +1,715 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
AVNI Cloud — control center.
|
||||||
|
|
||||||
|
Run bare: avni -> full-screen TUI
|
||||||
|
Or CLI: avni <command>
|
||||||
|
|
||||||
|
status host overview (gauges in TUI)
|
||||||
|
services containers + status + domain
|
||||||
|
domains domains + live HTTPS + cert expiry
|
||||||
|
traffic [24h|7d|30d] bandwidth charts (vnstat) + per-container net
|
||||||
|
perdomain per-domain requests + bytes (needs access log)
|
||||||
|
bans fail2ban jails + banned IPs
|
||||||
|
attackers [N] top N SSH brute-force IPs (+country)
|
||||||
|
ipinfo <ip> full intel on one IP (country/attempts/timeline/users)
|
||||||
|
geo <ip> country of an IP
|
||||||
|
ban|unban <ip> [jail] temporary fail2ban ban/unban
|
||||||
|
blacklist [add|del <x>] PERMANENT firewall blacklist (ip|cidr|domain)
|
||||||
|
whitelist fail2ban ignoreip
|
||||||
|
ssh sshd policy + sessions(+country) + recent logins
|
||||||
|
kick <ip|pts/N> kill SSH session(s) by IP or tty
|
||||||
|
users system users: sudo/live/locked/key/lastlogin
|
||||||
|
accesslog status|on Traefik per-domain access logging
|
||||||
|
Theme: AVNI ink #0e0a09 / cream #f2ddc0
|
||||||
|
"""
|
||||||
|
import curses, subprocess, shutil, re, json, os, sys, time, glob
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
IFACE_DEFAULT = None
|
||||||
|
TRAEFIK = "appwrite-traefik"
|
||||||
|
ACCESS_LOG = "/var/log/traefik/access.log"
|
||||||
|
FW = "/usr/local/bin/avni-firewall.sh"
|
||||||
|
AUTH_LOGS = ["/var/log/auth.log", "/var/log/auth.log.1"]
|
||||||
|
_GEO = {}
|
||||||
|
BLOCKS = "▁▂▃▄▅▆▇█"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- shell
|
||||||
|
def sh(cmd, timeout=8):
|
||||||
|
try: return subprocess.run(cmd.split(), capture_output=True, text=True, timeout=timeout).stdout.strip()
|
||||||
|
except Exception: return ""
|
||||||
|
def shl(args, timeout=8):
|
||||||
|
try: return subprocess.run(args, capture_output=True, text=True, timeout=timeout).stdout.strip()
|
||||||
|
except Exception: return ""
|
||||||
|
def sh_raw(cmd, timeout=8):
|
||||||
|
try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout).stdout
|
||||||
|
except Exception: return ""
|
||||||
|
def has(t): return shutil.which(t) is not None
|
||||||
|
|
||||||
|
def iface():
|
||||||
|
global IFACE_DEFAULT
|
||||||
|
if IFACE_DEFAULT: return IFACE_DEFAULT
|
||||||
|
m = re.search(r"default.* dev (\S+)", sh("ip route")); IFACE_DEFAULT = m.group(1) if m else "eth0"
|
||||||
|
return IFACE_DEFAULT
|
||||||
|
|
||||||
|
def human(n):
|
||||||
|
n=float(n)
|
||||||
|
for u in ("B","K","M","G","T"):
|
||||||
|
if n<1024: return f"{n:.0f}{u}" if u=="B" else f"{n:.1f}{u}"
|
||||||
|
n/=1024
|
||||||
|
return f"{n:.1f}P"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- charts (ascii)
|
||||||
|
def bar(val, mx, width, fill="█", empty="░"):
|
||||||
|
if mx<=0: return empty*width
|
||||||
|
f=int(round(width*min(val,mx)/mx)); return fill*f+empty*(width-f)
|
||||||
|
def spark(vals):
|
||||||
|
if not vals: return ""
|
||||||
|
mx=max(vals) or 1
|
||||||
|
return "".join(BLOCKS[min(len(BLOCKS)-1,int((v/mx)*(len(BLOCKS)-1)))] for v in vals)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- geo / intel
|
||||||
|
def geo(ip):
|
||||||
|
if ip in _GEO: return _GEO[ip]
|
||||||
|
c="?"
|
||||||
|
if has("geoiplookup"):
|
||||||
|
out=sh(f"geoiplookup {ip}")
|
||||||
|
m=re.search(r"GeoIP Country Edition:\s*[A-Z]{2},\s*(.+)", out)
|
||||||
|
if m: c=m.group(1).strip()
|
||||||
|
elif "not found" in out.lower() or "can't" in out.lower(): c="—"
|
||||||
|
_GEO[ip]=c; return c
|
||||||
|
|
||||||
|
def ts_of(line):
|
||||||
|
m=re.match(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})", line)
|
||||||
|
if m: return m.group(1).replace("T"," ")
|
||||||
|
m=re.match(r"([A-Z][a-z]{2}\s+\d+\s\d{2}:\d{2}:\d{2})", line)
|
||||||
|
return m.group(1) if m else "?"
|
||||||
|
|
||||||
|
def ip_intel(ip):
|
||||||
|
cnt=0; first=last=None; users={}
|
||||||
|
ipre=re.compile(re.escape(ip))
|
||||||
|
ure=re.compile(r"(?:Invalid user|user)\s+([A-Za-z0-9._-]+)")
|
||||||
|
for f in AUTH_LOGS:
|
||||||
|
if not os.path.exists(f): continue
|
||||||
|
try:
|
||||||
|
with open(f, errors="ignore") as fh:
|
||||||
|
for line in fh:
|
||||||
|
if ipre.search(line) and re.search(r"Failed|Invalid|authenticating|closed by|Disconnect", line):
|
||||||
|
cnt+=1; t=ts_of(line)
|
||||||
|
if first is None or t<first: first=t
|
||||||
|
if last is None or t>last: last=t
|
||||||
|
mu=ure.search(line)
|
||||||
|
if mu: users[mu.group(1)]=users.get(mu.group(1),0)+1
|
||||||
|
except Exception: pass
|
||||||
|
banned = any(ip in f2b_status(j)["ips"] for j in f2b_jails())
|
||||||
|
bl = subprocess.run(["ipset","test","avni_blacklist",ip],capture_output=True).returncode==0
|
||||||
|
org=""
|
||||||
|
if has("whois"):
|
||||||
|
w=sh(f"whois {ip}",10)
|
||||||
|
mo=re.search(r"(?:OrgName|org-name|netname|descr):\s*(.+)", w, re.I)
|
||||||
|
if mo: org=mo.group(1).strip()
|
||||||
|
topu=sorted(users.items(),key=lambda x:-x[1])[:6]
|
||||||
|
return {"ip":ip,"country":geo(ip),"attempts":cnt,"first":first or "—","last":last or "—",
|
||||||
|
"users":topu,"banned":banned,"blacklisted":bl,"org":org}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- docker / services
|
||||||
|
def overview():
|
||||||
|
mem=next((l for l in sh_raw("free -m").splitlines() if l.lower().startswith("mem")),"").split()
|
||||||
|
mtot,mused,mavail=(int(mem[1]),int(mem[2]),int(mem[-1])) if len(mem)>=7 else (0,0,0)
|
||||||
|
d=sh("df -h /").splitlines(); dl=d[1].split() if len(d)>1 else [""]*6
|
||||||
|
du=sh("df / ").splitlines(); dpct=du[1].split()[4] if len(du)>1 else "0%"
|
||||||
|
load=open("/proc/loadavg").read().split()[:3]
|
||||||
|
return {"host":sh("hostname"),"ip":(sh("curl -s -m4 https://api.ipify.org") or ""),
|
||||||
|
"os":sh_raw(". /etc/os-release; echo $PRETTY_NAME").strip(),"kernel":sh("uname -r"),
|
||||||
|
"uptime":sh("uptime -p"),"load":load,"ncpu":os.cpu_count() or 1,
|
||||||
|
"mem_total":mtot,"mem_used":mused,"mem_avail":mavail,
|
||||||
|
"disk_size":dl[1],"disk_used":dl[2],"disk_avail":dl[3],"disk_pct":dl[4]}
|
||||||
|
|
||||||
|
def traefik_domains():
|
||||||
|
out={}; ids=sh("docker ps -q").split()
|
||||||
|
if not ids: return out
|
||||||
|
try: data=json.loads(shl(["docker","inspect"]+ids,15) or "[]")
|
||||||
|
except Exception: return out
|
||||||
|
for c in data:
|
||||||
|
name=c.get("Name","").lstrip("/"); labels=(c.get("Config",{}) or {}).get("Labels",{}) or {}
|
||||||
|
for k,v in labels.items():
|
||||||
|
if k.endswith(".rule") and "Host(" in v:
|
||||||
|
for d in re.findall(r"Host\(`([^`]+)`\)",v): out.setdefault(d,name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def services():
|
||||||
|
rows=[]
|
||||||
|
out=shl(["docker","ps","-a","--format","{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"],12)
|
||||||
|
for line in out.splitlines():
|
||||||
|
p=line.split("\t")
|
||||||
|
if len(p)<3: continue
|
||||||
|
rows.append({"name":p[0],"image":p[1],"status":p[2],"ports":p[3] if len(p)>3 else ""})
|
||||||
|
c2d={}
|
||||||
|
for d,c in traefik_domains().items(): c2d.setdefault(c,[]).append(d)
|
||||||
|
for r in rows: r["domain"]=",".join(c2d.get(r["name"],[]))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def docker_net():
|
||||||
|
rows=[]
|
||||||
|
for line in shl(["docker","stats","--no-stream","--format","{{.Name}}\t{{.NetIO}}\t{{.CPUPerc}}"],20).splitlines():
|
||||||
|
p=line.split("\t")
|
||||||
|
if len(p)>=2: rows.append({"name":p[0],"net":p[1],"cpu":p[2] if len(p)>2 else ""})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def cert_expiry(domain):
|
||||||
|
out=sh_raw(f"echo | timeout 6 openssl s_client -servername {domain} -connect {domain}:443 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null")
|
||||||
|
m=re.search(r"notAfter=(.+)",out); return m.group(1).strip() if m else "?"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- fail2ban / blacklist
|
||||||
|
def f2b_jails():
|
||||||
|
m=re.search(r"Jail list:\s*(.*)",sh("fail2ban-client status"))
|
||||||
|
return [j.strip() for j in m.group(1).split(",")] if m and m.group(1).strip() else []
|
||||||
|
def f2b_status(jail):
|
||||||
|
out=sh(f"fail2ban-client status {jail}")
|
||||||
|
g=lambda p,d="0":(re.search(p,out).group(1) if re.search(p,out) else d)
|
||||||
|
ips=re.search(r"Banned IP list:\s*(.*)",out)
|
||||||
|
return {"failed":g(r"Currently failed:\s*(\d+)"),"banned":g(r"Currently banned:\s*(\d+)"),
|
||||||
|
"total":g(r"Total banned:\s*(\d+)"),"ips":ips.group(1).split() if ips and ips.group(1).strip() else []}
|
||||||
|
def f2b_banned_all():
|
||||||
|
return [{"jail":j,"ip":ip} for j in f2b_jails() for ip in f2b_status(j)["ips"]]
|
||||||
|
def f2b_ignoreip(jail="sshd"): return sh(f"fail2ban-client get {jail} ignoreip")
|
||||||
|
def ban(ip,jail="sshd"): return sh(f"fail2ban-client set {jail} banip {ip}")
|
||||||
|
def unban(ip,jail="sshd"): return sh(f"fail2ban-client set {jail} unbanip {ip}")
|
||||||
|
|
||||||
|
def blacklist_list(): return [x for x in sh(f"bash {FW} list").splitlines() if x.strip()]
|
||||||
|
def blacklist_add(x): return sh(f"bash {FW} add {x}",15)
|
||||||
|
def blacklist_del(x): return sh(f"bash {FW} del {x}",15)
|
||||||
|
def blacklist_count():
|
||||||
|
try: return int(sh(f"bash {FW} count") or "0")
|
||||||
|
except Exception: return 0
|
||||||
|
|
||||||
|
def attackers(n=15):
|
||||||
|
counts={}
|
||||||
|
pat=re.compile(r"(Failed password|Invalid user|authenticating user|Connection closed by|Disconnected from invalid)")
|
||||||
|
ipre=re.compile(r"(\d{1,3}\.){3}\d{1,3}")
|
||||||
|
for f in AUTH_LOGS:
|
||||||
|
if not os.path.exists(f): continue
|
||||||
|
try:
|
||||||
|
with open(f,errors="ignore") as fh:
|
||||||
|
for line in fh:
|
||||||
|
if pat.search(line):
|
||||||
|
m=ipre.search(line)
|
||||||
|
if m: counts[m.group(0)]=counts.get(m.group(0),0)+1
|
||||||
|
except Exception: pass
|
||||||
|
return [{"ip":ip,"count":c} for ip,c in sorted(counts.items(),key=lambda x:-x[1])[:n]]
|
||||||
|
|
||||||
|
def whois_ip(ip):
|
||||||
|
if not has("whois"): return "install: apt-get install -y whois"
|
||||||
|
keep=[l for l in sh(f"whois {ip}",10).splitlines() if re.match(r"^\s*(country|orgname|org-name|netname|descr|inetnum):",l,re.I)]
|
||||||
|
return "\n".join(keep[:8]) or "(no whois data)"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- IP registry (CrowdSec-style decisions store)
|
||||||
|
REG_PATH="/var/lib/avni/ips.json"
|
||||||
|
def registry_load():
|
||||||
|
try: return json.load(open(REG_PATH))
|
||||||
|
except Exception: return {}
|
||||||
|
def registry_save(d):
|
||||||
|
os.makedirs("/var/lib/avni",exist_ok=True)
|
||||||
|
try: json.dump(d,open(REG_PATH,"w"))
|
||||||
|
except Exception: pass
|
||||||
|
def registry_ips(reg): return [(i,e) for i,e in reg.items() if not i.startswith("_")]
|
||||||
|
|
||||||
|
def _scan_authlogs(offsets):
|
||||||
|
"""Read only NEW lines since last byte-offset (keyed by inode) -> fast."""
|
||||||
|
att={}; fs={}; ls={}; users={}; new=dict(offsets)
|
||||||
|
pat=re.compile(r"(Failed password|Invalid user|authenticating|closed by|Disconnect)")
|
||||||
|
ipre=re.compile(r"(\d{1,3}\.){3}\d{1,3}"); ure=re.compile(r"(?:Invalid user|user)\s+([A-Za-z0-9._-]+)")
|
||||||
|
for f in AUTH_LOGS:
|
||||||
|
if not os.path.exists(f): continue
|
||||||
|
try:
|
||||||
|
st=os.stat(f); ino=str(st.st_ino); start=offsets.get(ino,0)
|
||||||
|
if start>st.st_size: start=0 # rotated/truncated
|
||||||
|
with open(f,errors="ignore") as fh:
|
||||||
|
fh.seek(start)
|
||||||
|
for line in fh:
|
||||||
|
if not pat.search(line): continue
|
||||||
|
m=ipre.search(line)
|
||||||
|
if not m: continue
|
||||||
|
ip=m.group(0); t=ts_of(line); att[ip]=att.get(ip,0)+1
|
||||||
|
if ip not in fs or t<fs[ip]: fs[ip]=t
|
||||||
|
if ip not in ls or t>ls[ip]: ls[ip]=t
|
||||||
|
mu=ure.search(line)
|
||||||
|
if mu: users[ip]=mu.group(1)
|
||||||
|
new[ino]=fh.tell()
|
||||||
|
except Exception: pass
|
||||||
|
return att,fs,ls,users,new
|
||||||
|
|
||||||
|
def registry_update(full=False):
|
||||||
|
"""Incremental: only reads log bytes appended since last run. The persistent
|
||||||
|
store keeps every IP forever (even after logs rotate)."""
|
||||||
|
reg=registry_load(); now=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
meta=reg.get("_meta",{}); offsets={} if full else meta.get("offsets",{})
|
||||||
|
if "_meta" not in reg and registry_ips(reg) and not full:
|
||||||
|
# pre-incremental store already has counts: mark EOF, go incremental forward
|
||||||
|
for f in AUTH_LOGS:
|
||||||
|
if os.path.exists(f): offsets[str(os.stat(f).st_ino)]=os.stat(f).st_size
|
||||||
|
att,fs,ls,users=({},{},{},{})
|
||||||
|
else:
|
||||||
|
att,fs,ls,users,offsets=_scan_authlogs(offsets)
|
||||||
|
ipre=re.compile(r"(\d{1,3}\.){3}\d{1,3}")
|
||||||
|
logins={}
|
||||||
|
for line in sh("last -i -n 300").splitlines():
|
||||||
|
p=line.split()
|
||||||
|
if len(p)>=3 and ipre.fullmatch(p[2] or ""): logins[p[2]]=logins.get(p[2],0)+1
|
||||||
|
banned=set()
|
||||||
|
for j in f2b_jails(): banned|=set(f2b_status(j)["ips"])
|
||||||
|
bl=set(blacklist_list())
|
||||||
|
for ip in set(att)|set(logins)|{i for i,_ in registry_ips(reg)}|banned|bl:
|
||||||
|
e=reg.get(ip,{})
|
||||||
|
e["country"]=e.get("country") or geo(ip)
|
||||||
|
e["attempts"]=e.get("attempts",0)+att.get(ip,0)
|
||||||
|
if ip in fs: e["first"]=min(e.get("first",fs[ip]),fs[ip])
|
||||||
|
if ip in ls: e["last"]=max(e.get("last",""),ls[ip])
|
||||||
|
e["logins"]=max(e.get("logins",0),logins.get(ip,0))
|
||||||
|
e["banned"]=ip in banned; e["blacklisted"]=ip in bl
|
||||||
|
if ip in users: e["last_user"]=users[ip]
|
||||||
|
e.setdefault("first",now); e.setdefault("last",now)
|
||||||
|
reg[ip]=e
|
||||||
|
reg["_meta"]={"offsets":offsets,"updated":now}
|
||||||
|
registry_save(reg); return reg
|
||||||
|
|
||||||
|
def _recent(ts, hours=24):
|
||||||
|
try: return (datetime.now()-datetime.strptime(ts,"%Y-%m-%d %H:%M:%S")).total_seconds() < hours*3600
|
||||||
|
except Exception: return False
|
||||||
|
|
||||||
|
def registry_filter(reg, which):
|
||||||
|
v=list(reg.items())
|
||||||
|
if which=="Attackers": v=[(i,e) for i,e in v if e.get("attempts",0)>0 and not e.get("logins")]
|
||||||
|
elif which=="Banned": v=[(i,e) for i,e in v if e.get("banned") or e.get("blacklisted")]
|
||||||
|
elif which=="Unbanned": v=[(i,e) for i,e in v if (e.get("attempts",0)>0) and not e.get("banned") and not e.get("blacklisted")]
|
||||||
|
elif which=="New": v=[(i,e) for i,e in v if _recent(e.get("first",""))]
|
||||||
|
elif which=="Logins": v=[(i,e) for i,e in v if e.get("logins",0)>0]
|
||||||
|
key={"Attackers":"attempts","Logins":"logins"}.get(which)
|
||||||
|
v.sort(key=lambda x:-(x[1].get(key,0)) if key else x[1].get("last",""), reverse=bool(not key))
|
||||||
|
if key: v.sort(key=lambda x:-x[1].get(key,0))
|
||||||
|
return v
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- ssh / users
|
||||||
|
def ssh_sessions():
|
||||||
|
s=[]
|
||||||
|
for line in sh("who").splitlines():
|
||||||
|
p=line.split()
|
||||||
|
if len(p)>=5:
|
||||||
|
frm=p[-1].strip("()")
|
||||||
|
s.append({"user":p[0],"tty":p[1],"from":frm,"country":geo(frm) if re.match(r"\d+\.\d+\.\d+\.\d+",frm) else "local","since":" ".join(p[2:4])})
|
||||||
|
return s
|
||||||
|
def ssh_info():
|
||||||
|
t=sh("sshd -T",6)
|
||||||
|
g=lambda k:next((l.split(None,1)[1] for l in t.splitlines() if l.lower().startswith(k)),"?")
|
||||||
|
recent=[l for l in sh("last -a -n 14").splitlines() if l.strip() and not l.startswith("wtmp")]
|
||||||
|
return {"PermitRootLogin":g("permitrootlogin"),"PasswordAuthentication":g("passwordauthentication"),
|
||||||
|
"PubkeyAuthentication":g("pubkeyauthentication"),"Port":g("port"),
|
||||||
|
"sessions":ssh_sessions(),"recent":recent[:12],"attacker_ips":len(attackers(99999))}
|
||||||
|
|
||||||
|
def my_tty():
|
||||||
|
try: return os.ttyname(sys.stdin.fileno()).replace("/dev/","")
|
||||||
|
except Exception: return ""
|
||||||
|
def my_ip(): return os.environ.get("SSH_CLIENT","").split()[0] if os.environ.get("SSH_CLIENT") else ""
|
||||||
|
|
||||||
|
def kick_tty(tty):
|
||||||
|
if tty==my_tty(): return "refused: that is YOUR session"
|
||||||
|
subprocess.run(["pkill","-KILL","-t",tty]); return f"kicked session {tty}"
|
||||||
|
def kick_ip(ip):
|
||||||
|
if ip==my_ip(): return "refused: that is YOUR IP"
|
||||||
|
killed=[]
|
||||||
|
for s in ssh_sessions():
|
||||||
|
if s["from"]==ip and s["tty"]!=my_tty():
|
||||||
|
subprocess.run(["pkill","-KILL","-t",s["tty"]]); killed.append(s["tty"])
|
||||||
|
return f"kicked {ip}: "+(", ".join(killed) if killed else "no live session")
|
||||||
|
|
||||||
|
def users_list():
|
||||||
|
sudoers=set()
|
||||||
|
for grp in ("sudo","admin","wheel"):
|
||||||
|
m=sh(f"getent group {grp}")
|
||||||
|
if m and ":" in m: sudoers|=set(filter(None,m.split(":")[-1].split(",")))
|
||||||
|
live={}
|
||||||
|
for line in sh("who").splitlines():
|
||||||
|
u=line.split()[0] if line.split() else ""
|
||||||
|
live[u]=live.get(u,0)+1
|
||||||
|
rows=[]
|
||||||
|
for line in sh("getent passwd").splitlines():
|
||||||
|
p=line.split(":")
|
||||||
|
if len(p)<7: continue
|
||||||
|
name,uid,home,shell=p[0],int(p[2]),p[5],p[6]
|
||||||
|
if not(uid==0 or uid>=1000) or name=="nobody" or shell.endswith(("nologin","false")): continue
|
||||||
|
ll=sh(f"lastlog -u {name}").splitlines()
|
||||||
|
last=ll[1].split(None,3)[-1] if len(ll)>1 and "Never" not in ll[1] else "Never"
|
||||||
|
locked="L" in (sh(f"passwd -S {name}").split()[1:2] or [""])[0]
|
||||||
|
rows.append({"user":name,"uid":uid,"sudo":name in sudoers or uid==0,"shell":shell,
|
||||||
|
"lastlogin":last,"locked":locked,"key":os.path.exists(f"{home}/.ssh/authorized_keys"),
|
||||||
|
"live":live.get(name,0),"groups":sh(f"id -nG {name}")})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- traffic series
|
||||||
|
def vnstat_series(mode):
|
||||||
|
flag={"24h":"h","7d":"d","30d":"d"}.get(mode,"d")
|
||||||
|
try: j=json.loads(sh_raw(f"vnstat --json {flag} -i {iface()}",8) or "{}")
|
||||||
|
except Exception: j={}
|
||||||
|
out=[]
|
||||||
|
try:
|
||||||
|
node=j["interfaces"][0]["traffic"]
|
||||||
|
arr=node.get("hour") or node.get("hours") or node.get("day") or node.get("days") or []
|
||||||
|
for e in arr[-(24 if mode=="24h" else (7 if mode=="7d" else 30)):]:
|
||||||
|
d=e.get("date",{}); lab=(f"{d.get('hour',e.get('time',{}).get('hour','')):>2}h" if mode=="24h"
|
||||||
|
else f"{d.get('month','')}/{d.get('day','')}")
|
||||||
|
out.append({"label":lab,"rx":e.get("rx",0),"tx":e.get("tx",0)})
|
||||||
|
except Exception: pass
|
||||||
|
return out
|
||||||
|
def vnstat_oneline():
|
||||||
|
p=sh(f"vnstat --oneline -i {iface()}").split(";")
|
||||||
|
return {"today":p[5],"rx":p[3],"tx":p[4],"rate":p[6],"month":p[10],"all":p[13]} if len(p)>=14 else {}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- access log
|
||||||
|
def accesslog_status():
|
||||||
|
on="accesslog" in sh(f"docker inspect {TRAEFIK} --format "+"{{json .Args}}").lower()
|
||||||
|
return {"enabled":on,"logfile":os.path.exists(ACCESS_LOG)}
|
||||||
|
def perdomain():
|
||||||
|
if not os.path.exists(ACCESS_LOG): return None
|
||||||
|
reqs,byts={},{}
|
||||||
|
try:
|
||||||
|
with open(ACCESS_LOG,errors="ignore") as fh:
|
||||||
|
for line in fh:
|
||||||
|
try: jj=json.loads(line)
|
||||||
|
except Exception: continue
|
||||||
|
h=jj.get("RequestHost","?"); reqs[h]=reqs.get(h,0)+1; byts[h]=byts.get(h,0)+int(jj.get("DownstreamContentSize",0) or 0)
|
||||||
|
except Exception: return None
|
||||||
|
return sorted([{"domain":d,"reqs":reqs[d],"bytes":byts.get(d,0)} for d in reqs],key=lambda x:-x["reqs"])
|
||||||
|
|
||||||
|
# =========================================================================== CLI
|
||||||
|
def cli(argv):
|
||||||
|
cmd=argv[0] if argv else "status"; a=argv[1:]
|
||||||
|
if cmd in("status","overview"):
|
||||||
|
o=overview(); svc=services(); up=sum(1 for s in svc if s["status"].startswith("Up"))
|
||||||
|
print(f"AVNI Cloud — {o['host']} ({o['ip']})")
|
||||||
|
print(f" OS {o['os']} · kernel {o['kernel']}")
|
||||||
|
print(f" Uptime {o['uptime']} · Load {' '.join(o['load'])} (cpus {o['ncpu']})")
|
||||||
|
print(f" RAM {o['mem_used']}/{o['mem_total']}MB · Disk {o['disk_used']}/{o['disk_size']} ({o['disk_pct']})")
|
||||||
|
print(f" Services {up}/{len(svc)} up · Domains {len(traefik_domains())} · Banned {f2b_status('sshd')['banned']} · Blacklist {blacklist_count()}")
|
||||||
|
elif cmd=="services":
|
||||||
|
for s in services():
|
||||||
|
print(f" {'●' if s['status'].startswith('Up') else '○'} {s['name']:<34}{s['status']:<26}{s['domain']}")
|
||||||
|
elif cmd=="domains":
|
||||||
|
for d,c in sorted(traefik_domains().items()):
|
||||||
|
print(f" {d:<32} HTTP {sh(f'curl -s -m8 -o /dev/null -w %{{http_code}} https://{d}/'):<4} cert:{cert_expiry(d):<22} -> {c}")
|
||||||
|
elif cmd=="traffic":
|
||||||
|
win=a[0] if a else "7d"; ol=vnstat_oneline()
|
||||||
|
print(f" today {ol.get('today','?')} (rx {ol.get('rx','')}/tx {ol.get('tx','')}) rate {ol.get('rate','')} · month {ol.get('month','')}")
|
||||||
|
for e in vnstat_series(win):
|
||||||
|
tot=e["rx"]+e["tx"]; print(f" {e['label']:>6} {human(tot):>8} {bar(tot,max((x['rx']+x['tx']) for x in vnstat_series(win)),30)}")
|
||||||
|
print(" per-container:"); [print(f" {r['name']:<32}{r['net']}") for r in docker_net()]
|
||||||
|
elif cmd=="perdomain":
|
||||||
|
rows=perdomain()
|
||||||
|
if rows is None: print(" needs access log — run: avni accesslog on")
|
||||||
|
else:
|
||||||
|
for r in rows: print(f" {r['domain']:<32}{r['reqs']:>9} req {human(r['bytes']):>9}")
|
||||||
|
elif cmd=="bans":
|
||||||
|
for j in f2b_jails():
|
||||||
|
s=f2b_status(j); print(f" [{j}] banned={s['banned']} total={s['total']}")
|
||||||
|
for ip in s["ips"]: print(f" {ip:<18}{geo(ip)}")
|
||||||
|
elif cmd=="attackers":
|
||||||
|
n=int(a[0]) if a and a[0].isdigit() else 15; rows=attackers(n); mx=rows[0]["count"] if rows else 1
|
||||||
|
for r in rows: print(f" {r['count']:>7} {r['ip']:<17}{geo(r['ip']):<16}{bar(r['count'],mx,24)}")
|
||||||
|
elif cmd=="ipinfo":
|
||||||
|
if not a: sys.exit("usage: avni ipinfo <ip>")
|
||||||
|
d=ip_intel(a[0])
|
||||||
|
print(f" IP {d['ip']} ({d['country']})")
|
||||||
|
print(f" attempts {d['attempts']} · first {d['first']} · last {d['last']}")
|
||||||
|
print(f" banned={d['banned']} blacklisted={d['blacklisted']} org={d['org']}")
|
||||||
|
print(f" usernames tried: "+", ".join(f"{u}({c})" for u,c in d['users']))
|
||||||
|
elif cmd=="ips":
|
||||||
|
which=a[0].capitalize() if a else "All"
|
||||||
|
reg=registry_update(); rows=registry_filter(reg,which) if which!="All" else sorted(reg.items(),key=lambda x:-x[1].get("attempts",0))
|
||||||
|
print(f" {which}: {len(rows)} IPs (total tracked: {len(reg)})")
|
||||||
|
print(f" {'IP':<16}{'COUNTRY':<14}{'ATT':>7}{'LOGIN':>6} STATUS LAST")
|
||||||
|
for ip,e in rows[:60]:
|
||||||
|
st="BLACKLIST" if e.get("blacklisted") else ("BANNED" if e.get("banned") else ("login" if e.get("logins") else "seen"))
|
||||||
|
print(f" {ip:<16}{(e.get('country','?') or '?')[:13]:<14}{e.get('attempts',0):>7}{e.get('logins',0):>6} {st:<10} {e.get('last','')}")
|
||||||
|
elif cmd=="geo": print(geo(a[0]) if a else "usage: avni geo <ip>")
|
||||||
|
elif cmd=="ban": print(ban(a[0],a[1] if len(a)>1 else "sshd"),"banned",a[0]) if a else sys.exit("usage")
|
||||||
|
elif cmd=="unban": print(unban(a[0],a[1] if len(a)>1 else "sshd"),"unbanned",a[0]) if a else sys.exit("usage")
|
||||||
|
elif cmd=="blacklist":
|
||||||
|
if not a: [print(" "+x+" "+geo(x.split('/')[0])) for x in blacklist_list()] or (print(" (empty)") if not blacklist_list() else None)
|
||||||
|
elif a[0]=="add" and len(a)>1: print(blacklist_add(a[1]))
|
||||||
|
elif a[0]=="del" and len(a)>1: print(blacklist_del(a[1]))
|
||||||
|
else: print("usage: avni blacklist [add|del <ip|cidr|domain>]")
|
||||||
|
elif cmd=="whitelist": [print(f" [{j}] {f2b_ignoreip(j)}") for j in f2b_jails()]
|
||||||
|
elif cmd=="whois": print(whois_ip(a[0]) if a else "usage: avni whois <ip>")
|
||||||
|
elif cmd=="ssh":
|
||||||
|
s=ssh_info()
|
||||||
|
print(f" PermitRootLogin={s['PermitRootLogin']} PasswordAuth={s['PasswordAuthentication']} Pubkey={s['PubkeyAuthentication']} Port={s['Port']}")
|
||||||
|
print(f" attacker IPs in auth.log: {s['attacker_ips']}"); print(" live sessions:")
|
||||||
|
for x in s["sessions"]: print(f" {x['user']}@{x['tty']:<8} {x['from']:<16}({x['country']}) since {x['since']}")
|
||||||
|
print(" recent:"); [print(" "+r) for r in s["recent"]]
|
||||||
|
elif cmd=="kick": print(kick_ip(a[0]) if a and re.match(r"\d+\.\d+\.\d+\.\d+",a[0]) else (kick_tty(a[0]) if a else "usage: avni kick <ip|pts/N>"))
|
||||||
|
elif cmd=="users":
|
||||||
|
print(f" {'USER':<12}{'UID':>5} SUDO LIVE LOCK KEY LASTLOGIN")
|
||||||
|
for u in users_list():
|
||||||
|
print(f" {u['user']:<12}{u['uid']:>5} {'yes ' if u['sudo'] else '- '} {u['live']:>3} {'L' if u['locked'] else '-'} {'y' if u['key'] else 'n'} {u['lastlogin']}")
|
||||||
|
elif cmd=="accesslog": accesslog_cmd(a[0] if a else "status")
|
||||||
|
else: print(__doc__)
|
||||||
|
|
||||||
|
def accesslog_cmd(sub):
|
||||||
|
st=accesslog_status()
|
||||||
|
if sub=="status": print(f" enabled={st['enabled']} logfile={st['logfile']} path={ACCESS_LOG}"); return
|
||||||
|
print(" Enable per-domain stats (recreates the Appwrite Traefik ingress):")
|
||||||
|
print(" 1) <your-traefik-stack>/docker-compose.yml traefik command, add:")
|
||||||
|
print(" - --accesslog=true\n - --accesslog.filepath=/var/log/traefik/access.log\n - --accesslog.format=json")
|
||||||
|
print(" volumes: - /var/log/traefik:/var/log/traefik")
|
||||||
|
print(" 2) cd <your-traefik-stack> && docker compose up -d traefik 3) avni perdomain")
|
||||||
|
|
||||||
|
# =========================================================================== TUI
|
||||||
|
SECTIONS=["Overview","Services","Domains","Traffic","Security","SSH","Users"]
|
||||||
|
def init_colors():
|
||||||
|
curses.start_color(); curses.use_default_colors()
|
||||||
|
C=lambda n,f:curses.init_pair(n,f if curses.COLORS>=256 else f%8,-1)
|
||||||
|
cream=223 if curses.COLORS>=256 else 3
|
||||||
|
curses.init_pair(1,cream,-1); curses.init_pair(2,234 if curses.COLORS>=256 else 0,cream)
|
||||||
|
curses.init_pair(3,71 if curses.COLORS>=256 else 2,-1); curses.init_pair(4,203 if curses.COLORS>=256 else 1,-1)
|
||||||
|
curses.init_pair(5,245 if curses.COLORS>=256 else 7,-1); curses.init_pair(6,curses.COLOR_WHITE,-1)
|
||||||
|
curses.init_pair(7,214 if curses.COLORS>=256 else 3,-1); curses.init_pair(8,110 if curses.COLORS>=256 else 6,-1)
|
||||||
|
|
||||||
|
class TUI:
|
||||||
|
def __init__(self,scr):
|
||||||
|
self.scr=scr; self.sec=0; self.sel=0; self.tw=0
|
||||||
|
self.msg="Tab/←→ sections · ↑↓ select · Enter detail · keys in footer · q quit"; self.cache={}
|
||||||
|
def data(self,k,fn,ttl=4):
|
||||||
|
now=time.time()
|
||||||
|
if k not in self.cache or now-self.cache[k][0]>ttl:
|
||||||
|
try: self.cache[k]=(now,fn())
|
||||||
|
except Exception: self.cache[k]=(now,[])
|
||||||
|
return self.cache[k][1]
|
||||||
|
def rows(self):
|
||||||
|
s=SECTIONS[self.sec]
|
||||||
|
if s=="Services": return [(svc_line(r),r) for r in self.data("svc",services)]
|
||||||
|
if s=="Domains": return [(d,{"domain":d,"cont":c}) for d,c in sorted(self.data("dom",traefik_domains,8).items())]
|
||||||
|
if s=="Security": return self.sec_rows()
|
||||||
|
if s=="SSH": return [(None,x) for x in self.data("ssh",ssh_info,5)["sessions"]]
|
||||||
|
if s=="Users": return [(None,u) for u in self.data("usr",users_list,10)]
|
||||||
|
return []
|
||||||
|
def sec_rows(self):
|
||||||
|
atk=self.data("atk",lambda:attackers(14),12); mx=atk[0]["count"] if atk else 1
|
||||||
|
rows=[]
|
||||||
|
for r in atk:
|
||||||
|
rows.append((None,{"kind":"atk","ip":r["ip"],"count":r["count"],"mx":mx}))
|
||||||
|
return rows
|
||||||
|
def clampsel(self,n): self.sel=max(0,min(self.sel,n-1)) if n else 0
|
||||||
|
|
||||||
|
def draw(self):
|
||||||
|
scr=self.scr; scr.erase(); h,w=scr.getmaxyx()
|
||||||
|
scr.attron(curses.color_pair(2)); scr.addstr(0,0," "*(w-1))
|
||||||
|
scr.addstr(0,1,"AVNI CLOUD · control center"[:w-2]); scr.attroff(curses.color_pair(2))
|
||||||
|
clk=datetime.now().strftime("%H:%M:%S")
|
||||||
|
if w>12: scr.addstr(0,w-len(clk)-1,clk,curses.color_pair(1))
|
||||||
|
x=0
|
||||||
|
for i,s in enumerate(SECTIONS):
|
||||||
|
lab=f" {i+1}.{s} "
|
||||||
|
if x+len(lab)>=w: break
|
||||||
|
scr.addstr(1,x,lab,curses.color_pair(2) if i==self.sec else curses.color_pair(5)); x+=len(lab)
|
||||||
|
scr.hline(2,0,curses.ACS_HLINE,w-1)
|
||||||
|
try: self.body(scr,3,h-2,w)
|
||||||
|
except Exception as e: scr.addstr(4,2,f"(render: {e})"[:w-3],curses.color_pair(4))
|
||||||
|
scr.attron(curses.color_pair(2)); scr.addstr(h-1,0," "*(w-1))
|
||||||
|
scr.addstr(h-1,1,self.msg[:max(0,w-22)]); scr.attroff(curses.color_pair(2))
|
||||||
|
foot=self.footer()
|
||||||
|
if w>len(foot)+2: scr.addstr(h-1,w-len(foot)-1,foot,curses.color_pair(2))
|
||||||
|
scr.refresh()
|
||||||
|
|
||||||
|
def gauge(self,scr,y,x,label,used,total,w,unit=""):
|
||||||
|
pct=(used/total) if total else 0
|
||||||
|
col=3 if pct<0.7 else (7 if pct<0.9 else 4)
|
||||||
|
scr.addstr(y,x,f"{label:<8}",curses.color_pair(1))
|
||||||
|
bw=max(8,w-30); b=bar(used,total,bw)
|
||||||
|
fill=int(round(bw*min(pct,1)))
|
||||||
|
scr.addstr(y,x+9,b[:fill],curses.color_pair(col)); scr.addstr(y,x+9+fill,b[fill:],curses.color_pair(5))
|
||||||
|
scr.addstr(y,x+10+bw,f"{pct*100:4.0f}% {used}/{total}{unit}"[:24],curses.color_pair(6))
|
||||||
|
|
||||||
|
def body(self,scr,top,bot,w):
|
||||||
|
s=SECTIONS[self.sec]
|
||||||
|
if s=="Overview": return self.b_overview(scr,top,bot,w)
|
||||||
|
if s=="Traffic": return self.b_traffic(scr,top,bot,w)
|
||||||
|
if s=="SSH": return self.b_ssh(scr,top,bot,w)
|
||||||
|
if s=="Users": return self.b_users(scr,top,bot,w)
|
||||||
|
if s=="Security": return self.b_security(scr,top,bot,w)
|
||||||
|
rows=self.rows(); self.clampsel(len(rows))
|
||||||
|
if not rows: scr.addstr(top+1,2,"(nothing)",curses.color_pair(5)); return
|
||||||
|
view=bot-top-1; start=max(0,self.sel-view+1)
|
||||||
|
for i,(line,obj) in enumerate(rows[start:start+view]):
|
||||||
|
idx=start+i; y=top+1+i; txt=line if line else str(obj)
|
||||||
|
if SECTIONS[self.sec]=="Domains": txt=f"{obj['domain']:<34} → {obj['cont']}"
|
||||||
|
scr.addstr(y,2,(txt[:w-4]).ljust(w-4) if idx==self.sel else txt[:w-4],
|
||||||
|
curses.color_pair(2) if idx==self.sel else color_for(txt))
|
||||||
|
|
||||||
|
def b_overview(self,scr,top,bot,w):
|
||||||
|
o=self.data("ov",overview,5); svc=self.data("svc",services); up=sum(1 for x in svc if x["status"].startswith("Up"))
|
||||||
|
ol=self.data("vol",vnstat_oneline,6)
|
||||||
|
info=[("Host",f"{o['host']} {o['ip']}"),("OS",f"{o['os']} · {o['kernel']}"),("Uptime",o["uptime"])]
|
||||||
|
for i,(k,v) in enumerate(info):
|
||||||
|
scr.addstr(top+1+i,2,f"{k:<8}",curses.color_pair(1)); scr.addstr(top+1+i,11,str(v)[:w-13],curses.color_pair(6))
|
||||||
|
y=top+5
|
||||||
|
la=float(o["load"][0]); self.gauge(scr,y,2,"Load",round(la,2),o["ncpu"],w,"")
|
||||||
|
self.gauge(scr,y+1,2,"Memory",o["mem_used"],o["mem_total"],w,"MB")
|
||||||
|
dpct=int(re.sub(r"\D","",o["disk_pct"]) or 0); self.gauge(scr,y+2,2,"Disk",dpct,100,w,"%")
|
||||||
|
y+=4
|
||||||
|
scr.addstr(y,2,f"Services {up}/{len(svc)} up Domains {len(self.data('dom',traefik_domains,8))} "
|
||||||
|
f"Banned {f2b_status('sshd')['banned']} Blacklist {self.data('blc',blacklist_count,10)}",curses.color_pair(1))
|
||||||
|
scr.addstr(y+2,2,f"Net today {ol.get('today','?')} (rx {ol.get('rx','')} / tx {ol.get('tx','')}) rate {ol.get('rate','')} · month {ol.get('month','')}",curses.color_pair(6))
|
||||||
|
att=self.data("atk",lambda:attackers(14),12)
|
||||||
|
scr.addstr(y+4,2,"Attack pressure (top IPs): "+spark([a["count"] for a in att]),curses.color_pair(4))
|
||||||
|
|
||||||
|
def b_traffic(self,scr,top,bot,w):
|
||||||
|
win=["24h","7d","30d"][self.tw%3]
|
||||||
|
scr.addstr(top+1,2,f"Window < {win} > (↑↓ switches 24h / 7d / 30d)",curses.color_pair(1))
|
||||||
|
ser=self.data("vn"+win,lambda:vnstat_series(win),20)
|
||||||
|
ol=self.data("vol",vnstat_oneline,6)
|
||||||
|
scr.addstr(top+2,2,f"today {ol.get('today','?')} rate {ol.get('rate','')} month {ol.get('month','')}",curses.color_pair(6))
|
||||||
|
y=top+4; mx=max((e["rx"]+e["tx"]) for e in ser) if ser else 1
|
||||||
|
for e in ser[-(bot-top-6):]:
|
||||||
|
tot=e["rx"]+e["tx"]
|
||||||
|
scr.addstr(y,2,f"{e['label']:>6} {human(tot):>8} ",curses.color_pair(6))
|
||||||
|
bw=max(6,w-40); scr.addstr(y,18,bar(tot,mx,bw),curses.color_pair(8)); y+=1
|
||||||
|
if y>=bot-1: break
|
||||||
|
if not ser: scr.addstr(y,2,"(vnstat is still collecting — history fills in over the coming days)",curses.color_pair(5))
|
||||||
|
|
||||||
|
def b_ssh(self,scr,top,bot,w):
|
||||||
|
s=self.data("ssh",ssh_info,5); warn=s["PasswordAuthentication"]=="yes"
|
||||||
|
scr.addstr(top+1,2,f"PermitRootLogin {s['PermitRootLogin']} PasswordAuth {s['PasswordAuthentication']} Pubkey {s['PubkeyAuthentication']} Port {s['Port']}",
|
||||||
|
curses.color_pair(4 if warn else 3))
|
||||||
|
scr.addstr(top+2,2,f"distinct attacker IPs in auth.log: {s['attacker_ips']}",curses.color_pair(1))
|
||||||
|
scr.addstr(top+4,2,"LIVE SESSIONS (↑↓ select · k kick session · K kick+blacklist IP):",curses.color_pair(1))
|
||||||
|
sess=s["sessions"]; self.clampsel(len(sess)); y=top+5
|
||||||
|
for i,x in enumerate(sess):
|
||||||
|
mark="»" if i==self.sel else " "; me=" (you)" if x["tty"]==my_tty() else ""
|
||||||
|
line=f"{mark} {x['user']}@{x['tty']:<9}{x['from']:<16} {x['country']:<14} since {x['since']}{me}"
|
||||||
|
scr.addstr(y,2,line[:w-4],curses.color_pair(2) if i==self.sel else curses.color_pair(6)); y+=1
|
||||||
|
y+=1; scr.addstr(y,2,"recent logins:",curses.color_pair(1)); y+=1
|
||||||
|
for r in s["recent"]:
|
||||||
|
if y>=bot-1: break
|
||||||
|
scr.addstr(y,4,r[:w-6],curses.color_pair(5)); y+=1
|
||||||
|
|
||||||
|
def b_users(self,scr,top,bot,w):
|
||||||
|
us=self.data("usr",users_list,10); self.clampsel(len(us))
|
||||||
|
scr.addstr(top+1,2,f"{'USER':<12}{'UID':>5} SUDO LIVE LOCK KEY LASTLOGIN (↑↓ · L lock · Uu unlock)",curses.color_pair(1))
|
||||||
|
y=top+2
|
||||||
|
for i,u in enumerate(us):
|
||||||
|
mark="»" if i==self.sel else " "
|
||||||
|
line=(f"{mark}{u['user']:<12}{u['uid']:>5} {'yes' if u['sudo'] else ' - ':<4} {u['live']:>3} "
|
||||||
|
f"{'L' if u['locked'] else '-'} {'y' if u['key'] else 'n'} {u['lastlogin']}")
|
||||||
|
scr.addstr(y,2,line[:w-4],curses.color_pair(2) if i==self.sel else (curses.color_pair(4) if u['locked'] else curses.color_pair(6))); y+=1
|
||||||
|
if self.sel<len(us):
|
||||||
|
u=us[self.sel]; scr.addstr(bot-1,2,f"groups: {u['groups']}"[:w-4],curses.color_pair(5))
|
||||||
|
|
||||||
|
def b_security(self,scr,top,bot,w):
|
||||||
|
banned=self.data("ban",f2b_banned_all,4); bl=self.data("blc2",blacklist_list,8)
|
||||||
|
scr.addstr(top+1,2,f"fail2ban banned: {len(banned)} permanent blacklist: {len(bl)} "
|
||||||
|
"(b ban · B blacklist · u unban · w whois · Enter detail · g blacklist view)",curses.color_pair(1))
|
||||||
|
scr.addstr(top+3,2,"TOP ATTACKERS (attempts · country):",curses.color_pair(1))
|
||||||
|
rows=self.sec_rows(); self.clampsel(len(rows)); y=top+4
|
||||||
|
for i,(_,o) in enumerate(rows[:bot-top-5]):
|
||||||
|
mark="»" if i==self.sel else " "
|
||||||
|
bnd="●BL" if (subprocess.run(["ipset","test","avni_blacklist",o["ip"]],capture_output=True).returncode==0) else ("●b" if any(o["ip"] in f2b_status(j)["ips"] for j in ["sshd"]) else " ")
|
||||||
|
line=f"{mark}{o['count']:>7} {o['ip']:<16}{geo(o['ip']):<14}{bnd:<4}"
|
||||||
|
scr.addstr(y,2,line[:w-34],curses.color_pair(2) if i==self.sel else curses.color_pair(6))
|
||||||
|
scr.addstr(y,min(w-30,2+len(line)),bar(o["count"],o["mx"],24),curses.color_pair(4)); y+=1
|
||||||
|
|
||||||
|
def footer(self):
|
||||||
|
s=SECTIONS[self.sec]
|
||||||
|
return {"Security":"b ban·B blacklist·u unban·w whois·Enter detail·r·q",
|
||||||
|
"Services":"s start·x stop·R restart·l logs·r·q",
|
||||||
|
"SSH":"k kick·K kick+blacklist·r·q","Users":"L lock·U unlock·r·q",
|
||||||
|
"Domains":"Enter detail·r·q","Traffic":"↑↓ window·r·q"}.get(s,"r refresh·q quit")
|
||||||
|
|
||||||
|
def detail_ip(self,ip):
|
||||||
|
d=ip_intel(ip)
|
||||||
|
lines=[f"IP {d['ip']} ({d['country']})",
|
||||||
|
f"attempts {d['attempts']}",
|
||||||
|
f"first {d['first']}", f"last {d['last']}",
|
||||||
|
f"banned {d['banned']} blacklisted {d['blacklisted']}",
|
||||||
|
f"org {d['org']}",
|
||||||
|
"usernames "+", ".join(f"{u}({c})" for u,c in d["users"]) or "usernames —","",
|
||||||
|
"[b]an temp [B]lacklist permanent [u]nban [w]hois [any] close"]
|
||||||
|
ch=self.popup(f"IP intel · {ip}",lines,wait=True)
|
||||||
|
if ch in (ord('b'),): self.msg="ban "+ip+": "+(ban(ip) or "ok"); self.cache.pop("ban",None)
|
||||||
|
elif ch==ord('B'): self.msg=blacklist_add(ip); self.cache.pop("blc2",None)
|
||||||
|
elif ch==ord('u'): self.msg="unban "+ip+": "+(unban(ip) or "ok"); self.cache.pop("ban",None)
|
||||||
|
elif ch==ord('w'): self.popup("whois "+ip,whois_ip(ip).splitlines(),wait=True)
|
||||||
|
|
||||||
|
def act(self,ch):
|
||||||
|
s=SECTIONS[self.sec]; rows=self.rows(); obj=rows[self.sel][1] if rows and self.sel<len(rows) else None
|
||||||
|
if s=="Traffic" and ch in (curses.KEY_UP,curses.KEY_DOWN,ord('j'),ord('k')):
|
||||||
|
self.tw+=1 if ch in (curses.KEY_DOWN,ord('j')) else -1; return
|
||||||
|
if s=="Security":
|
||||||
|
o=self.sec_rows()[self.sel][1] if self.sec_rows() and self.sel<len(self.sec_rows()) else None
|
||||||
|
ip=o["ip"] if o else None
|
||||||
|
if ch in (curses.KEY_ENTER,10,13) and ip: self.detail_ip(ip)
|
||||||
|
elif ch==ord('b') and ip: self.msg="ban "+ip+": "+(ban(ip) or "ok"); self.cache.pop("ban",None)
|
||||||
|
elif ch==ord('B') and ip: self.msg=blacklist_add(ip); self.cache.pop("blc2",None); self.cache.pop("blc",None)
|
||||||
|
elif ch==ord('u') and ip: self.msg="unban "+ip+": "+(unban(ip) or "ok"); self.cache.pop("ban",None)
|
||||||
|
elif ch==ord('w') and ip: self.popup("whois "+ip,whois_ip(ip).splitlines(),wait=True)
|
||||||
|
elif ch==ord('g'): self.popup("Permanent blacklist",[x+" "+geo(x.split('/')[0]) for x in blacklist_list()] or ["(empty)"],wait=True)
|
||||||
|
elif ch==ord('a'):
|
||||||
|
v=self.prompt("ban/blacklist IP or domain: ")
|
||||||
|
if v: self.msg=blacklist_add(v); self.cache.pop("blc2",None)
|
||||||
|
elif s=="Services" and obj:
|
||||||
|
n=obj["name"]
|
||||||
|
if ch==ord('R'): self.msg=f"restarting {n}…"; sh(f"docker restart {n}",40); self.cache.pop("svc",None); self.msg=f"restarted {n}"
|
||||||
|
elif ch==ord('x'): sh(f"docker stop {n}",40); self.cache.pop("svc",None); self.msg=f"stopped {n}"
|
||||||
|
elif ch==ord('s'): sh(f"docker start {n}",40); self.cache.pop("svc",None); self.msg=f"started {n}"
|
||||||
|
elif ch==ord('l'): self.popup("logs "+n,sh(f"docker logs --tail 60 {n}",10).splitlines(),wait=True)
|
||||||
|
elif s=="SSH" and obj:
|
||||||
|
if ch==ord('k'): self.msg=kick_tty(obj["tty"]); self.cache.pop("ssh",None)
|
||||||
|
elif ch==ord('K'): self.msg=kick_ip(obj["from"])+" + "+blacklist_add(obj["from"]); self.cache.pop("ssh",None)
|
||||||
|
elif s=="Domains" and obj and ch in (curses.KEY_ENTER,10,13):
|
||||||
|
d=obj["domain"]; code=sh(f"curl -s -m8 -o /dev/null -w %{{http_code}} https://{d}/")
|
||||||
|
self.popup("domain · "+d,[f"backend {obj['cont']}",f"HTTP {code}",f"cert exp {cert_expiry(d)}",
|
||||||
|
f"IPs {sh('getent ahostsv4 '+d).splitlines()[0].split()[0] if sh('getent ahostsv4 '+d) else '?'}"],wait=True)
|
||||||
|
elif s=="Users" and obj:
|
||||||
|
if ch==ord('L') and obj["user"] not in("root",): sh(f"usermod -L {obj['user']}"); self.cache.pop("usr",None); self.msg=f"locked {obj['user']}"
|
||||||
|
elif ch in (ord('U'),ord('u')): sh(f"usermod -U {obj['user']}"); self.cache.pop("usr",None); self.msg=f"unlocked {obj['user']}"
|
||||||
|
|
||||||
|
def prompt(self,label):
|
||||||
|
curses.echo(); curses.curs_set(1); h,w=self.scr.getmaxyx()
|
||||||
|
self.scr.addstr(h-1,0," "*(w-1),curses.color_pair(2)); self.scr.addstr(h-1,1,label,curses.color_pair(2))
|
||||||
|
try: v=self.scr.getstr(h-1,1+len(label),50).decode().strip()
|
||||||
|
except Exception: v=""
|
||||||
|
curses.noecho(); curses.curs_set(0); return v
|
||||||
|
def popup(self,title,lines,wait=False):
|
||||||
|
h,w=self.scr.getmaxyx(); win=curses.newwin(min(h-4,len(lines)+4),w-6,2,3); win.box()
|
||||||
|
win.addstr(0,2,f" {title} ",curses.color_pair(1))
|
||||||
|
for i,l in enumerate(lines[:h-7]):
|
||||||
|
try: win.addstr(1+i,2,str(l)[:w-10],curses.color_pair(6))
|
||||||
|
except Exception: pass
|
||||||
|
win.refresh(); ch=win.getch() if wait else -1; return ch
|
||||||
|
|
||||||
|
def loop(self):
|
||||||
|
curses.curs_set(0); self.scr.timeout(1500)
|
||||||
|
while True:
|
||||||
|
self.draw(); ch=self.scr.getch()
|
||||||
|
if ch==-1: continue
|
||||||
|
if ch in (ord('q'),27): break
|
||||||
|
elif ch in (curses.KEY_RIGHT,9): self.sec=(self.sec+1)%len(SECTIONS); self.sel=0
|
||||||
|
elif ch==curses.KEY_LEFT: self.sec=(self.sec-1)%len(SECTIONS); self.sel=0
|
||||||
|
elif ord('1')<=ch<=ord('7'): self.sec=ch-ord('1'); self.sel=0
|
||||||
|
elif ch in (curses.KEY_DOWN,) and SECTIONS[self.sec]!="Traffic": self.sel+=1
|
||||||
|
elif ch in (curses.KEY_UP,) and SECTIONS[self.sec]!="Traffic": self.sel=max(0,self.sel-1)
|
||||||
|
elif ch==ord('r'): self.cache.clear(); self.msg="refreshed"
|
||||||
|
else: self.act(ch)
|
||||||
|
|
||||||
|
def svc_line(r):
|
||||||
|
return f"{'●' if r['status'].startswith('Up') else '○'} {r['name']:<32}{r['status']:<24}{(' '+r['domain']) if r['domain'] else ''}"
|
||||||
|
def color_for(line):
|
||||||
|
if line.startswith("○") or "unhealthy" in line: return curses.color_pair(4)
|
||||||
|
if line.startswith("●"): return curses.color_pair(3)
|
||||||
|
return curses.color_pair(6)
|
||||||
|
def tui_main(scr): init_colors(); TUI(scr).loop()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv)>1: cli(sys.argv[1:])
|
||||||
|
elif not sys.stdout.isatty(): print("avni: not a TTY. Try a subcommand (avni status) or run in a terminal."); sys.exit(1)
|
||||||
|
else: curses.wrapper(tui_main)
|
||||||
|
|
||||||
|
if __name__=="__main__": main()
|
||||||
119
docs/guide.md
Normal file
119
docs/guide.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# AVNI Cloud Control Center (`avni`)
|
||||||
|
|
||||||
|
A single command to see and manage the whole VPS. **Rewritten in Rust/Ratatui**
|
||||||
|
(16-06-2026) for speed — ~2% CPU vs the old Python build's ~22%, microsecond
|
||||||
|
render, incremental log parsing.
|
||||||
|
|
||||||
|
- Rust source: `src/main.rs` (`cargo build --release` at the repo root)
|
||||||
|
- **Deploy/rebuild: `sudo bash install.sh`** (builds + installs the binary +
|
||||||
|
sets up the access group/sudoers). The binary at `/usr/local/bin/avni` is a **real file**
|
||||||
|
(root:root 0755) — NOT a symlink, so authorised non-root users can exec it.
|
||||||
|
- Python fallback: `avni.py` (optional; install as `/usr/local/bin/avni-py`)
|
||||||
|
- Firewall blacklist engine: `avni-firewall.sh` (installed to `/usr/local/bin/avni-firewall.sh`)
|
||||||
|
- IP registry (persistent): `/var/lib/avni/ips.json`
|
||||||
|
|
||||||
|
## Who can run it (access control)
|
||||||
|
`avni` is **restricted to root + members of the `avni` group**. A non-root user who runs it
|
||||||
|
is auto-elevated by re-exec'ing through `sudo` (drop-in `/etc/sudoers.d/avni`, NOPASSWD for the
|
||||||
|
`avni` group). A user with no access just gets a "not authorised" message.
|
||||||
|
**Rule: grant the tool ONLY to users who are already admins (sudo).** Make a user an admin in the
|
||||||
|
**Users** tab (`G`) first, then grant the tool in the **Settings** tab (`g`). Grant refuses
|
||||||
|
non-admins. Root can never be revoked.
|
||||||
|
|
||||||
|
## Open it
|
||||||
|
```bash
|
||||||
|
avni # full-screen TUI (run in a real terminal / SSH session)
|
||||||
|
```
|
||||||
|
Keys: `1-9` or `Tab`/`←→` switch tabs · `↑↓` (or `j/k`) select · `r` refresh ·
|
||||||
|
`q` quit. Tabs: **Overview · Services · Domains · Traffic · Security · SSH · Users · Settings · Apps**.
|
||||||
|
|
||||||
|
**Times & IST**: the VPS runs on Europe/Berlin (CEST). Every displayed time has the
|
||||||
|
**IST equivalent beside it** (e.g. `11:25 (14:55 IST)`); the Overview shows both clocks.
|
||||||
|
|
||||||
|
Per-tab actions (shown in the footer):
|
||||||
|
- **Services**: `s` start · `x` stop · `R` restart · `l` logs
|
||||||
|
- **Domains**: `Enter` = HTTP code + TLS cert expiry
|
||||||
|
- **Traffic**: `↑↓` switch window 24h / 7d / 30d (vnstat bar chart)
|
||||||
|
- **Security**: **`[` `]` switch sub-tabs** · **`a` = ADD blacklist** / **`A` = ADD whitelist**
|
||||||
|
(type IP/CIDR/domain, Enter confirms, Esc cancels) · `b` temp-ban · `B` blacklist selected ·
|
||||||
|
`W` whitelist selected · `u` unban / un-whitelist · `w` whois · `Enter` IP intel · `g` list blacklist
|
||||||
|
- **SSH**: `k` kick session · `K` kick + blacklist its IP
|
||||||
|
- **Users**: `Enter` **activity** (logins, failed logins, sudo commands, processes — all
|
||||||
|
system metadata, never reads anyone's home) · `N` new user (type `username` or
|
||||||
|
`username password`) · `P` **set login password** (Linux/SSH password — type it in the
|
||||||
|
masked box, min 8 chars, no spaces or `' " \` \ $`) · `G` grant sudo · `g` revoke sudo ·
|
||||||
|
`L` lock · `U` unlock · `D` delete (confirm with `y`). **root is protected**: its sudo
|
||||||
|
can't be revoked, and it can't be locked or deleted (its password *can* be set). Delete
|
||||||
|
removes the **account only** — `/home` and files are kept (safe on this multi-tenant box).
|
||||||
|
CLI: `avni adduser <u> [pw]` · `avni passwd <u> <pw>` · `avni sudo <u>` · `avni unsudo <u>` · `avni deluser <u>` · `avni activity <u>`.
|
||||||
|
- **Settings**: who may use `avni`. `g` grant the tool to the selected user · `x` revoke ·
|
||||||
|
`Enter` activity. **A user must already be an admin (sudo)** — grant refuses non-admins.
|
||||||
|
Root always has access and can't be revoked. CLI: `avni access` · `avni grant <u>` · `avni revoke <u>`.
|
||||||
|
- **Apps** (Applications): the user-facing apps Traefik routes — auto-discovered from each
|
||||||
|
container's `Host()` label, so the list never goes stale. Shows STATE (enabled/disabled),
|
||||||
|
SUBDOMAIN, internal PORT and published HOST PORTS. `s` enable (start) · `x` disable (stop) ·
|
||||||
|
`R` restart · `l` logs · `Enter` details (live HTTPS code + TLS cert expiry + compose dir).
|
||||||
|
CLI: `avni apps`. (Appwrite itself isn't listed — it's the Traefik host / catch-all, managed
|
||||||
|
via its own stack, and you wouldn't want a one-key "disable" on the thing that owns 80/443.)
|
||||||
|
|
||||||
|
## Security tab — the IP registry (CrowdSec-inspired)
|
||||||
|
Every IP that has touched the box is stored in `/var/lib/avni/ips.json` with
|
||||||
|
country, attempts, logins, first/last seen, ban/blacklist status — and it
|
||||||
|
**persists across log rotation**. **Switch sub-tabs with `[` and `]`** (the active
|
||||||
|
one is highlighted in the cream bar):
|
||||||
|
`Attackers · Banned · Whitelist · Unbanned · New · Logins · All`.
|
||||||
|
|
||||||
|
Three kinds of decision:
|
||||||
|
- **Temp ban** (`b`) — fail2ban, expires after the jail bantime.
|
||||||
|
- **Permanent blacklist** (`a` to type / `B` on selected / `K` from SSH) — ipset +
|
||||||
|
iptables at the firewall, covers host **and** Docker-published ports, survives
|
||||||
|
reboot, never expires. Remove: `avni-firewall.sh del <ip>`.
|
||||||
|
- **Whitelist** (`A` to type / `W` on selected) — an IP that can **never** be banned
|
||||||
|
or blacklisted, even under brute force. It's added to fail2ban `ignoreip` AND a
|
||||||
|
firewall guard refuses to blacklist it. **Whitelist your own home/office IPs first**
|
||||||
|
so a brute-force burst can never lock you out. Anti-lockout also refuses to blacklist
|
||||||
|
your current SSH client IP. CLI: `avni whitelist [add|del <ip>]`.
|
||||||
|
|
||||||
|
The UI loads data in a **background thread**, so typing in the add box and switching
|
||||||
|
tabs stay instant even while it refreshes. **Actions reflect instantly** — grant/revoke,
|
||||||
|
lock, ban/blacklist/whitelist update the on-screen row immediately (optimistic), then the
|
||||||
|
background re-gather reconciles with the real system state.
|
||||||
|
|
||||||
|
## CLI (scriptable / same data)
|
||||||
|
```bash
|
||||||
|
avni status # host + counts
|
||||||
|
avni services # ALL containers + status + domain
|
||||||
|
avni apps # user-facing apps only: state · subdomain · port · host ports
|
||||||
|
avni ips [attackers|banned|unbanned|new|logins|all]
|
||||||
|
avni attackers [N] # top N with country
|
||||||
|
avni ban <ip> | unban <ip>
|
||||||
|
avni blacklist [add <ip|cidr|domain>]
|
||||||
|
avni geo <ip> | ssh | users
|
||||||
|
avni adduser <user> [password] # create account (-m home, bash); root-safe
|
||||||
|
avni passwd <user> <password> # set a user's Linux login / SSH password (root-safe)
|
||||||
|
avni sudo <user> | unsudo <user> # grant / revoke sudo (refuses root)
|
||||||
|
avni deluser <user> # delete account, KEEP /home (refuses root)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Speed design (inspired by k9s / lazydocker / ctop / GoAccess / CrowdSec)
|
||||||
|
- **Loading splash** on startup until the first snapshot lands (no blank shell).
|
||||||
|
- **Parallel gather**: the independent fetches (docker, auth-log registry, sessions,
|
||||||
|
users, vnstat, counts) fan out across threads, so a full refresh is ~2 s (the slowest
|
||||||
|
single part) instead of the ~11 s sum. Public IP is fetched once (was a 4 s curl every refresh).
|
||||||
|
- **Incremental log parsing**: stores a byte-offset per log inode and reads only
|
||||||
|
newly-appended lines, not the whole 17 MB `auth.log`, on each refresh.
|
||||||
|
- **Traffic** pins the default-route interface (eth0) — the box has ~80 docker
|
||||||
|
veth/bridge interfaces, so vnstat's interfaces[0] was a zero-traffic bridge (empty graph).
|
||||||
|
All 3 windows (24h/7d/30d) are precomputed, so switching is instant; bars show human values.
|
||||||
|
- **Cached data**, refreshed every ~10 s or on `r`; rendering is decoupled from
|
||||||
|
data gathering.
|
||||||
|
- Shells out to the system tools (docker, fail2ban-client, ipset, vnstat,
|
||||||
|
geoiplookup, who/last/getent) — no daemon, no heavy deps.
|
||||||
|
- GoAccess is installed for future per-domain HTTP stats (needs Traefik access
|
||||||
|
logs; `avni-py accesslog on` prints the opt-in steps).
|
||||||
|
|
||||||
|
## Build / rebuild
|
||||||
|
```bash
|
||||||
|
cargo build --release # at the repo root
|
||||||
|
# (toolchain: rustup stable; a distro rustc that is too old for ratatui 0.26 will fail — use rustup)
|
||||||
|
```
|
||||||
54
install.sh
Normal file
54
install.sh
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build + install the AVNI control-center, and set up access delegation.
|
||||||
|
# - binary -> /usr/local/bin/avni (real file, so granted non-root users can exec it)
|
||||||
|
# - firewall helper -> /usr/local/bin/avni-firewall.sh (used by the blacklist/whitelist engine)
|
||||||
|
# - 'avni' Unix group = who may use the tool (root always can)
|
||||||
|
# - sudoers drop-in lets 'avni' members auto-elevate (the binary re-execs via sudo)
|
||||||
|
#
|
||||||
|
# Run from anywhere — the script locates its own directory, so the repo can be cloned
|
||||||
|
# to any path. Re-run after every code change to redeploy. Usage: sudo bash install.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BIN=/usr/local/bin/avni
|
||||||
|
FWBIN=/usr/local/bin/avni-firewall.sh
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then echo "!! run as root: sudo bash install.sh"; exit 1; fi
|
||||||
|
|
||||||
|
# locate cargo (rustup install lives under the invoking user's home even under sudo)
|
||||||
|
CARGO="$(command -v cargo || true)"
|
||||||
|
[ -z "$CARGO" ] && [ -x "$HOME/.cargo/bin/cargo" ] && CARGO="$HOME/.cargo/bin/cargo"
|
||||||
|
[ -z "$CARGO" ] && [ -n "${SUDO_USER:-}" ] && [ -x "/home/$SUDO_USER/.cargo/bin/cargo" ] && CARGO="/home/$SUDO_USER/.cargo/bin/cargo"
|
||||||
|
if [ -z "$CARGO" ]; then echo "!! cargo not found — install Rust via https://rustup.rs"; exit 1; fi
|
||||||
|
|
||||||
|
echo "==> building (release)"
|
||||||
|
( cd "$SRC" && "$CARGO" build --release >/dev/null )
|
||||||
|
|
||||||
|
echo "==> 'avni' access group"
|
||||||
|
groupadd -f avni
|
||||||
|
|
||||||
|
echo "==> sudoers drop-in (auto-elevation for granted users)"
|
||||||
|
cat > /etc/sudoers.d/avni <<'EOF'
|
||||||
|
# AVNI control-center — members of the 'avni' group may run the tool as root.
|
||||||
|
# They are already admins (sudo); granting 'avni' just makes `avni` launch seamlessly.
|
||||||
|
%avni ALL=(root) NOPASSWD: /usr/local/bin/avni
|
||||||
|
EOF
|
||||||
|
chmod 440 /etc/sudoers.d/avni
|
||||||
|
if ! visudo -cf /etc/sudoers.d/avni >/dev/null; then
|
||||||
|
echo "!! sudoers validation failed — removing drop-in"; rm -f /etc/sudoers.d/avni; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> installing firewall helper -> $FWBIN"
|
||||||
|
install -o root -g root -m 0755 "$SRC/avni-firewall.sh" "$FWBIN"
|
||||||
|
|
||||||
|
echo "==> installing binary -> $BIN"
|
||||||
|
rm -f "$BIN"
|
||||||
|
install -o root -g root -m 0755 "$SRC/target/release/avni" "$BIN"
|
||||||
|
|
||||||
|
# optional: pure-Python fallback
|
||||||
|
if [ -f "$SRC/avni.py" ]; then
|
||||||
|
install -o root -g root -m 0755 "$SRC/avni.py" /usr/local/bin/avni-py
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "OK. root + 'avni' group members can run: avni"
|
||||||
|
echo "Current avni-group members: $(getent group avni | cut -d: -f4)"
|
||||||
1327
src/main.rs
Normal file
1327
src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user