From 8612b38bee06f257f4c582703a12e77be4a5609b Mon Sep 17 00:00:00 2001 From: Jagadeesh Date: Wed, 24 Jun 2026 20:36:08 +0200 Subject: [PATCH] first commit Co-Authored-By: Claude Opus 4.8 --- .gitignore | 11 + Cargo.lock | 568 ++++++++++++++++++++ Cargo.toml | 18 + LICENSE | 21 + README.md | 201 +++++++ avni-firewall.sh | 75 +++ avni.py | 715 +++++++++++++++++++++++++ docs/guide.md | 119 +++++ install.sh | 54 ++ src/main.rs | 1327 ++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 3109 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100755 avni-firewall.sh create mode 100755 avni.py create mode 100644 docs/guide.md create mode 100644 install.sh create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64c5100 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Rust build output +/target +**/*.rs.bk + +# Python +__pycache__/ +*.pyc + +# editor / OS +*.swp +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..718004f --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3a7562e --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8c31626 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2609aac --- /dev/null +++ b/README.md @@ -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 | unban +avni blacklist [add ] +avni whitelist [add|del ] +avni geo | ssh | users +avni adduser [password] # create account (-m home, bash) +avni passwd # set a user's Linux login / SSH password +avni sudo | unsudo # grant / revoke sudo +avni deluser # delete account, KEEP /home +avni activity # logins, failed logins, sudo cmds, processes +avni access | grant | revoke # 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 ` (Users tab `G`). +2. Grant the tool — `avni grant ` (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). diff --git a/avni-firewall.sh b/avni-firewall.sh new file mode 100755 index 0000000..c40806c --- /dev/null +++ b/avni-firewall.sh @@ -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 |del |list|count|whitelist {add|del|list} }"; exit 1 ;; +esac diff --git a/avni.py b/avni.py new file mode 100755 index 0000000..592b102 --- /dev/null +++ b/avni.py @@ -0,0 +1,715 @@ +#!/usr/bin/env python3 +""" +AVNI Cloud — control center. + +Run bare: avni -> full-screen TUI +Or CLI: avni + + 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 full intel on one IP (country/attempts/timeline/users) + geo country of an IP + ban|unban [jail] temporary fail2ban ban/unban + blacklist [add|del ] PERMANENT firewall blacklist (ip|cidr|domain) + whitelist fail2ban ignoreip + ssh sshd policy + sessions(+country) + recent logins + kick 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 tlast: 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 tls[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 ") + 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 ") + 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 ]") + 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 ") + 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 ")) + 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) /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 && 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.sel7} {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.sel1: 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() diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..0ef5a08 --- /dev/null +++ b/docs/guide.md @@ -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 [pw]` · `avni passwd ` · `avni sudo ` · `avni unsudo ` · `avni deluser ` · `avni activity `. +- **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 ` · `avni revoke `. +- **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 `. +- **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 ]`. + +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 | unban +avni blacklist [add ] +avni geo | ssh | users +avni adduser [password] # create account (-m home, bash); root-safe +avni passwd # set a user's Linux login / SSH password (root-safe) +avni sudo | unsudo # grant / revoke sudo (refuses root) +avni deluser # 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) +``` diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..daeb263 --- /dev/null +++ b/install.sh @@ -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)" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ec5119c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1327 @@ +// AVNI Cloud — control center (Rust/Ratatui). Fast: incremental log parsing, +// cached data, shells out to docker/fail2ban/ipset/vnstat/geoiplookup. +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::{self, BufRead, BufReader, Seek, SeekFrom}; +use std::os::unix::fs::MetadataExt; +use std::process::Command; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; +use ratatui::prelude::*; +use ratatui::widgets::*; +use serde::{Deserialize, Serialize}; + +const AUTH_LOGS: [&str; 2] = ["/var/log/auth.log", "/var/log/auth.log.1"]; +// Canonical install location (placed by install.sh). The firewall helper is a +// sibling script so the tool works regardless of where the repo was cloned. +const FW: &str = "/usr/local/bin/avni-firewall.sh"; +const TABS: [&str; 9] = ["Overview", "Services", "Domains", "Traffic", "Security", "SSH", "Users", "Settings", "Apps"]; +const SUBS: [&str; 7] = ["Attackers", "Banned", "Whitelist", "Unbanned", "New", "Logins", "All"]; +const CREAM: Color = Color::Rgb(242, 221, 192); +const INK: Color = Color::Rgb(14, 10, 9); + +// ----------------------------------------------------------------- shell + util +fn sh(cmd: &str) -> String { + Command::new("sh").arg("-c").arg(cmd).output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim_end().to_string()) + .unwrap_or_default() +} +fn human(n: f64) -> String { + let mut n = n; for u in ["B", "K", "M", "G", "T"] { + if n < 1024.0 { return if u == "B" { format!("{:.0}{}", n, u) } else { format!("{:.1}{}", n, u) }; } + n /= 1024.0; + } format!("{:.1}P", n) +} +fn now_str() -> String { sh("date '+%Y-%m-%d %H:%M:%S'") } + +// IST = +05:30. Minutes to add to a SERVER-local time to get IST (server may be on any TZ). +fn ist_delta_min() -> i64 { + let z = sh("date +%z"); // e.g. "+0200" or "-0530" + let sign = if z.starts_with('-') { -1 } else { 1 }; + let d: String = z.chars().filter(|c| c.is_ascii_digit()).collect(); + let off = if d.len() >= 4 { sign * (d[0..2].parse::().unwrap_or(0) * 60 + d[2..4].parse::().unwrap_or(0)) } else { 0 }; + 330 - off +} +// HH:MM of (server) h:m shifted into IST. Offset is constant, so only the clock matters. +fn ist_of_hm(h: i64, m: i64, delta: i64) -> String { + let t = (h * 60 + m + delta).rem_euclid(1440); + format!("{:02}:{:02}", t / 60, t % 60) +} +// Find the first HH:MM[:SS] token in a line and append " (HH:MM IST)". No-op if no time found. +fn with_ist(line: &str, delta: i64) -> String { + for tok in line.split_whitespace() { + let p: Vec<&str> = tok.split(':').collect(); + if p.len() >= 2 && p[0].len() <= 2 && !p[0].is_empty() { + if let (Ok(h), Ok(m)) = (p[0].parse::(), p[1].parse::()) { + if h < 24 && m < 60 { return format!("{} ({} IST)", line, ist_of_hm(h, m, delta)); } + } + } + } + line.to_string() +} + +fn valid_ip(s: &str) -> Option { + let parts: Vec<&str> = s.split('.').collect(); + if parts.len() == 4 && parts.iter().all(|p| !p.is_empty() && p.len() <= 3 && p.parse::().map(|n| n <= 255).unwrap_or(false)) { + Some(s.to_string()) + } else { None } +} +fn find_ip(s: &str) -> Option { + let b = s.as_bytes(); let mut i = 0; + while i < b.len() { + if b[i].is_ascii_digit() { + let start = i; let mut j = i; let mut dots = 0; + while j < b.len() && (b[j].is_ascii_digit() || b[j] == b'.') { if b[j] == b'.' { dots += 1; } j += 1; } + if dots == 3 { if let Some(ip) = valid_ip(&s[start..j]) { return Some(ip); } } + i = j; + } else { i += 1; } + } + None +} +fn ts_of(line: &str) -> String { + // ISO "2026-06-16T22:13:23" or syslog "Jun 16 22:13:23" + if line.len() >= 19 && line.as_bytes()[4] == b'-' { + return line[..19].replace('T', " "); + } + line.split_whitespace().take(3).collect::>().join(" ") +} +fn user_of(line: &str) -> Option { + let toks: Vec<&str> = line.split_whitespace().collect(); + for (i, t) in toks.iter().enumerate() { + if (*t == "user" || *t == "Invalid") && i + 1 < toks.len() { + let u = toks[i + 1].trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '.' && c != '-'); + if !u.is_empty() && u != "user" { return Some(u.to_string()); } + } + } + None +} + +fn geo_cache() -> &'static Mutex> { + static C: OnceLock>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(HashMap::new())) +} +fn geo(ip: &str) -> String { + if let Some(c) = geo_cache().lock().unwrap().get(ip) { return c.clone(); } + let out = sh(&format!("geoiplookup {}", ip)); + let c = out.split("Country Edition:").nth(1) + .and_then(|s| s.split(',').nth(1)).map(|s| s.trim().to_string()) + .unwrap_or_else(|| "?".into()); + geo_cache().lock().unwrap().insert(ip.to_string(), c.clone()); c +} + +// ----------------------------------------------------------------- data structs +#[derive(Default, Clone)] +struct Overview { host: String, ip: String, os: String, kernel: String, uptime: String, + load: [f64; 3], ncpu: f64, mem_used: u64, mem_total: u64, disk_pct: u64, disk_used: String, disk_size: String, + time: String, ist: String } +#[derive(Default, Clone)] +struct Svc { name: String, status: String, domain: String } +// A user-facing "application" = a container Traefik actually routes (has a Host() rule). +#[derive(Default, Clone)] +struct AppRow { name: String, status: String, domain: String, port: String, hostports: String, compose: String } +#[derive(Default, Clone)] +struct Sess { user: String, tty: String, from: String, country: String, since: String, me: bool } +#[derive(Default, Clone)] +struct UserRow { user: String, uid: String, sudo: bool, live: u32, locked: bool, key: bool, last: String, groups: String, access: bool } +#[derive(Default, Clone)] +struct SshCfg { root: String, pass: String, pubkey: String, port: String, attackers: usize } +#[derive(Serialize, Deserialize, Clone, Default)] +struct IpRec { + #[serde(default)] country: String, #[serde(default)] attempts: u64, #[serde(default)] logins: u64, + #[serde(default)] first: String, #[serde(default)] last: String, + #[serde(default)] banned: bool, #[serde(default)] blacklisted: bool, #[serde(default)] last_user: String, + #[serde(default)] whitelisted: bool, +} + +// ----------------------------------------------------------------- fetchers +fn overview() -> Overview { + let mut o = Overview::default(); + o.host = sh("hostname"); o.ip = public_ip(); + o.os = sh(". /etc/os-release; echo $PRETTY_NAME"); o.kernel = sh("uname -r"); o.uptime = sh("uptime -p"); + o.time = sh("date '+%Y-%m-%d %H:%M:%S %Z'"); o.ist = sh("TZ=Asia/Kolkata date '+%H:%M %Z'"); + let la = fs::read_to_string("/proc/loadavg").unwrap_or_default(); + let lp: Vec = la.split_whitespace().take(3).filter_map(|x| x.parse().ok()).collect(); + if lp.len() == 3 { o.load = [lp[0], lp[1], lp[2]]; } + o.ncpu = num_cpus() as f64; + for l in sh("free -m").lines() { + if l.to_lowercase().starts_with("mem") { + let p: Vec<&str> = l.split_whitespace().collect(); + if p.len() >= 3 { o.mem_total = p[1].parse().unwrap_or(0); o.mem_used = p[2].parse().unwrap_or(0); } + } + } + let d = sh("df -h /"); let dl: Vec<&str> = d.lines().nth(1).unwrap_or("").split_whitespace().collect(); + if dl.len() >= 5 { o.disk_used = dl[2].into(); o.disk_size = dl[1].into(); o.disk_pct = dl[4].trim_end_matches('%').parse().unwrap_or(0); } + o +} +fn num_cpus() -> usize { sh("nproc").parse().unwrap_or(1) } +fn is_private(ip: &str) -> bool { + ip.starts_with("10.") || ip.starts_with("192.168.") || ip.starts_with("127.") + || ip.strip_prefix("172.").and_then(|r| r.split('.').next()).and_then(|o| o.parse::().ok()).map(|n| (16..=31).contains(&n)).unwrap_or(false) +} +// Public IP — fetched ONCE and cached. On this VPS the uplink IP is already public, +// so we read it locally (instant) and only fall back to an external curl if the route +// IP is private (NAT). This removes the curl from the hot path entirely. +fn public_ip() -> String { + static C: OnceLock = OnceLock::new(); + C.get_or_init(|| { + let local = sh("ip route get 1.1.1.1 2>/dev/null").split_whitespace().skip_while(|t| *t != "src").nth(1).unwrap_or("").to_string(); + if valid_ip(&local).is_some() && !is_private(&local) { return local; } // already public → done + let i = sh("curl -s -m2 https://api.ipify.org"); + if valid_ip(&i).is_some() { i } else { local } + }).clone() +} + +// ONE docker call for names+status+domains. `docker ps --format` is ~5x faster than +// `docker inspect` on a busy box (no daemon round-trip per container). Traefik Host() +// rules are parsed straight out of the flat Labels string. +fn docker_snapshot() -> (Vec, Vec<(String, String)>, Vec) { + let out = sh("docker ps -a --no-trunc --format '{{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Labels}}'"); + let mut svcs = Vec::new(); + let mut doms: Vec<(String, String)> = Vec::new(); + let mut apps: Vec = Vec::new(); + for l in out.lines() { + let p: Vec<&str> = l.splitn(4, '\t').collect(); + if p.len() < 2 { continue; } + let (name, status) = (p[0].to_string(), p[1].to_string()); + let portsf = p.get(2).copied().unwrap_or(""); + let labels = p.get(3).copied().unwrap_or(""); + let mut hosts: Vec = Vec::new(); + let mut rest = labels; + while let Some(i) = rest.find("Host(`") { + let after = &rest[i + 6..]; + if let Some(j) = after.find('`') { hosts.push(after[..j].to_string()); rest = &after[j + 1..]; } + else { break; } + } + hosts.sort(); hosts.dedup(); + for h in &hosts { doms.push((h.clone(), name.clone())); } + svcs.push(Svc { name: name.clone(), status: status.clone(), domain: hosts.join(",") }); + // an "application" = a container Traefik routes (has a Host() rule) + if !hosts.is_empty() { + apps.push(AppRow { + name, status, domain: hosts.join(","), + port: label_val(labels, "loadbalancer.server.port=").unwrap_or_default(), + hostports: tidy_ports(portsf), + compose: label_val(labels, "com.docker.compose.project.working_dir=").unwrap_or_default(), + }); + } + } + doms.sort(); doms.dedup(); + apps.sort_by(|a, b| a.name.cmp(&b.name)); + (svcs, doms, apps) +} +// value of a comma-separated `k=v` docker label, found by the key's suffix (e.g. "...port=") +fn label_val(labels: &str, key: &str) -> Option { + labels.find(key).map(|i| labels[i + key.len()..].split(',').next().unwrap_or("").to_string()) + .filter(|v| !v.is_empty()) +} +// compact docker `.Ports` -> just the host:container maps (drop /tcp, 0.0.0.0:, IPv6 dups) +fn tidy_ports(s: &str) -> String { + let mut seen: Vec = Vec::new(); + for part in s.split(", ") { + if part.contains("::") { continue; } + let p = part.replace("/tcp", "").replace("0.0.0.0:", "").trim().to_string(); + if !p.is_empty() && !seen.contains(&p) { seen.push(p); } + } + seen.join(" ") +} +fn domains() -> Vec<(String, String)> { docker_snapshot().1 } +fn services() -> Vec { docker_snapshot().0 } +fn app_catalog() -> Vec { docker_snapshot().2 } +fn docker_net() -> Vec<(String, String)> { + sh("docker stats --no-stream --format '{{.Name}}\t{{.NetIO}}'").lines() + .filter_map(|l| { let p: Vec<&str> = l.splitn(2, '\t').collect(); if p.len() == 2 { Some((p[0].into(), p[1].into())) } else { None } }).collect() +} +// The real uplink (default-route interface). The box has ~80 docker veth/bridge +// interfaces, so vnstat's interfaces[0] is often a zero-traffic bridge — we MUST +// pin the right one (usually eth0) or the graph reads all zeros. +fn primary_iface() -> String { + let o = sh("ip route get 1.1.1.1 2>/dev/null"); + o.split_whitespace().skip_while(|t| *t != "dev").nth(1) + .filter(|s| !s.is_empty()).map(|s| s.to_string()).unwrap_or_else(|| "eth0".into()) +} +// Full hourly ("h") or daily ("d") series for one interface. Called ONCE in the +// background gather; the render slices the last N cheaply, so switching windows never forks. +fn vnstat_full(flag: &str, iface: &str) -> Vec<(String, u64)> { + let out = sh(&format!("vnstat -i {} --json {} 2>/dev/null", iface, flag)); + let v: serde_json::Value = serde_json::from_str(&out).unwrap_or(serde_json::Value::Null); + let key = if flag == "h" { "hour" } else { "day" }; + let mut res = vec![]; + if let Some(arr) = v["interfaces"][0]["traffic"].get(key).and_then(|x| x.as_array()) { + for e in arr { + let rx = e["rx"].as_u64().unwrap_or(0); let tx = e["tx"].as_u64().unwrap_or(0); + let d = &e["date"]; + let lab = if flag == "h" { format!("{:02}h", d["hour"].as_u64().unwrap_or(0)) } + else { format!("{}/{}", d["month"].as_u64().unwrap_or(0), d["day"].as_u64().unwrap_or(0)) }; + res.push((lab, rx + tx)); + } + } + res +} +fn vnstat_today() -> String { + let p: Vec = sh("vnstat --oneline").split(';').map(|s| s.to_string()).collect(); + if p.len() >= 7 { format!("today {} (rx {} / tx {}) rate {}", p[5], p[3], p[4], p[6]) } else { "today: (vnstat collecting)".into() } +} + +fn f2b_jails() -> Vec { + let out = sh("fail2ban-client status"); + out.lines().find(|l| l.contains("Jail list:")).map(|l| l.split(':').nth(1).unwrap_or("").split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()).unwrap_or_default() +} +fn f2b_banned() -> HashSet { + let mut set = HashSet::new(); + for j in f2b_jails() { + let out = sh(&format!("fail2ban-client status {}", j)); + if let Some(l) = out.lines().find(|l| l.contains("Banned IP list:")) { + for ip in l.split(':').nth(1).unwrap_or("").split_whitespace() { set.insert(ip.to_string()); } + } + } + set +} +fn f2b_count() -> usize { f2b_banned().len() } +fn ban(ip: &str) -> String { sh(&format!("fail2ban-client set sshd banip {}", ip)); format!("banned {}", ip) } +fn unban(ip: &str) -> String { sh(&format!("fail2ban-client set sshd unbanip {}", ip)); format!("unbanned {}", ip) } +fn blacklist_list() -> Vec { sh(&format!("bash {} list", FW)).lines().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() } +fn blacklist_add(x: &str) -> String { sh(&format!("bash {} add {}", FW, x)) } +fn whitelist_list() -> Vec { sh(&format!("bash {} whitelist list", FW)).lines().map(|s| s.trim().to_string()).filter(|s| !s.is_empty() && !s.starts_with('(')).collect() } +fn whitelist_add(x: &str) -> String { sh(&format!("bash {} whitelist add {}", FW, x)) } +fn whitelist_del(x: &str) -> String { sh(&format!("bash {} whitelist del {}", FW, x)) } +fn whois_ip(ip: &str) -> String { + sh(&format!("whois {} 2>/dev/null", ip)).lines() + .filter(|l| { let lc = l.to_lowercase(); lc.starts_with("country") || lc.contains("orgname") || lc.starts_with("netname") || lc.starts_with("descr") || lc.starts_with("inetnum") }) + .take(8).collect::>().join("\n") +} + +// ----------------------------------------------------------------- registry +fn reg_load_map() -> (HashMap, HashMap) { + let mut map = HashMap::new(); let mut off = HashMap::new(); + if let Ok(s) = fs::read_to_string("/var/lib/avni/ips.json") { + if let Ok(v) = serde_json::from_str::(&s) { + if let Some(obj) = v.as_object() { + for (k, val) in obj { + if k == "_meta" { + if let Some(o) = val.get("offsets").and_then(|o| o.as_object()) { + for (i, p) in o { off.insert(i.clone(), p.as_u64().unwrap_or(0)); } + } + } else if !k.starts_with('_') { + if let Ok(r) = serde_json::from_value::(val.clone()) { map.insert(k.clone(), r); } + } + } + } + } + } + (map, off) +} +fn reg_save_map(map: &HashMap, off: &HashMap) { + let _ = fs::create_dir_all("/var/lib/avni"); + let mut obj = serde_json::Map::new(); + for (k, r) in map { obj.insert(k.clone(), serde_json::to_value(r).unwrap()); } + let mut offo = serde_json::Map::new(); + for (i, p) in off { offo.insert(i.clone(), serde_json::json!(p)); } + let mut meta = serde_json::Map::new(); meta.insert("offsets".into(), serde_json::Value::Object(offo)); + obj.insert("_meta".into(), serde_json::Value::Object(meta)); + let _ = fs::write("/var/lib/avni/ips.json", serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default()); +} +fn reg_update() -> HashMap { + let (mut map, mut off) = reg_load_map(); + let first_run = off.is_empty() && !map.is_empty(); + let (mut att, mut fsm, mut lsm, mut users): (HashMap, HashMap, HashMap, HashMap) = Default::default(); + for f in AUTH_LOGS { + if let Ok(meta) = fs::metadata(f) { + let ino = meta.ino().to_string(); let size = meta.len(); + if first_run { off.insert(ino, size); continue; } + let mut start = *off.get(&ino).unwrap_or(&0); if start > size { start = 0; } + if let Ok(mut file) = fs::File::open(f) { + let _ = file.seek(SeekFrom::Start(start)); + let mut pos = start; + for line in BufReader::new(file).lines().flatten() { + pos += line.len() as u64 + 1; + if line.contains("Failed password") || line.contains("Invalid user") || line.contains("authenticating") || line.contains("closed by") || line.contains("Disconnect") { + if let Some(ip) = find_ip(&line) { + *att.entry(ip.clone()).or_insert(0) += 1; + let t = ts_of(&line); + fsm.entry(ip.clone()).and_modify(|e| { if t < *e { *e = t.clone(); } }).or_insert(t.clone()); + lsm.entry(ip.clone()).and_modify(|e| { if t > *e { *e = t.clone(); } }).or_insert(t.clone()); + if let Some(u) = user_of(&line) { users.insert(ip.clone(), u); } + } + } + } + off.insert(ino, pos); + } + } + } + let mut logins: HashMap = HashMap::new(); + for line in sh("last -i -n 300").lines() { + let p: Vec<&str> = line.split_whitespace().collect(); + if p.len() >= 3 { if let Some(ip) = valid_ip(p[2]) { *logins.entry(ip).or_insert(0) += 1; } } + } + let banned = f2b_banned(); + let bl: HashSet = blacklist_list().into_iter().collect(); + let wl: HashSet = whitelist_list().into_iter().collect(); + let mut keys: HashSet = map.keys().cloned().collect(); + keys.extend(att.keys().cloned()); keys.extend(logins.keys().cloned()); + keys.extend(banned.iter().cloned()); keys.extend(bl.iter().cloned()); keys.extend(wl.iter().cloned()); + let now = now_str(); + for ip in keys { + let e = map.entry(ip.clone()).or_default(); + if e.country.is_empty() { e.country = geo(&ip); } + e.attempts += *att.get(&ip).unwrap_or(&0); + if let Some(t) = fsm.get(&ip) { if e.first.is_empty() || *t < e.first { e.first = t.clone(); } } + if let Some(t) = lsm.get(&ip) { if *t > e.last { e.last = t.clone(); } } + let lg = *logins.get(&ip).unwrap_or(&0); if lg > e.logins { e.logins = lg; } + e.banned = banned.contains(&ip); e.blacklisted = bl.contains(&ip); e.whitelisted = wl.contains(&ip); + if let Some(u) = users.get(&ip) { e.last_user = u.clone(); } + if e.first.is_empty() { e.first = now.clone(); } + if e.last.is_empty() { e.last = now.clone(); } + } + reg_save_map(&map, &off); + map +} +// SUBS order: 0 Attackers, 1 Banned, 2 Whitelist, 3 Unbanned, 4 New, 5 Logins, 6 All +fn reg_filter(reg: &HashMap, which: usize) -> Vec<(String, IpRec)> { + let mut v: Vec<(String, IpRec)> = reg.iter().map(|(k, r)| (k.clone(), r.clone())).collect(); + // ISO timestamps ("YYYY-MM-DD HH:MM:SS") sort chronologically, so "New" is a + // pure string compare to a cutoff computed ONCE (was forking `date` per IP -> lag). + let cutoff = if which == 4 { sh("date -d '-24 hours' '+%Y-%m-%d %H:%M:%S'") } else { String::new() }; + match which { + // Attackers: anything with failed attempts that ISN'T a trusted login-only/whitelisted IP. + // Banned/blacklisted IPs always qualify (so test-bans show even if you also log in from them). + 0 => v.retain(|(_, e)| e.attempts > 0 && !e.whitelisted && (e.banned || e.blacklisted || e.logins == 0)), + 1 => v.retain(|(_, e)| e.banned || e.blacklisted), + 2 => v.retain(|(_, e)| e.whitelisted), + 3 => v.retain(|(_, e)| e.attempts > 0 && !e.banned && !e.blacklisted && !e.whitelisted), + 4 => v.retain(|(_, e)| !e.first.is_empty() && e.first.as_str() >= cutoff.as_str()), + 5 => v.retain(|(_, e)| e.logins > 0), + _ => {} + } + if which == 5 { v.sort_by(|a, b| b.1.logins.cmp(&a.1.logins)); } + else { v.sort_by(|a, b| b.1.attempts.cmp(&a.1.attempts)); } + v +} + +fn sessions() -> Vec { + let me_tty = sh("tty").replace("/dev/", ""); + let me_ip = std::env::var("SSH_CLIENT").ok().and_then(|s| s.split_whitespace().next().map(|x| x.to_string())).unwrap_or_default(); + let delta = ist_delta_min(); + sh("who").lines().filter_map(|l| { + let p: Vec<&str> = l.split_whitespace().collect(); + if p.len() >= 5 { + let from = p[p.len() - 1].trim_matches(|c| c == '(' || c == ')').to_string(); + let country = if valid_ip(&from).is_some() { geo(&from) } else { "local".into() }; + Some(Sess { user: p[0].into(), tty: p[1].into(), me: p[1] == me_tty || from == me_ip, + from, country, since: with_ist(&format!("{} {}", p[2], p[3]), delta) }) + } else { None } + }).collect() +} +fn ssh_cfg() -> SshCfg { + let t = sh("sshd -T"); + let g = |k: &str| t.lines().find(|l| l.to_lowercase().starts_with(k)).and_then(|l| l.split_whitespace().nth(1)).unwrap_or("?").to_string(); + SshCfg { root: g("permitrootlogin"), pass: g("passwordauthentication"), pubkey: g("pubkeyauthentication"), port: g("port"), attackers: 0 } +} +fn users_list() -> Vec { + let mut sudoers = HashSet::new(); + for grp in ["sudo", "admin", "wheel"] { + let m = sh(&format!("getent group {}", grp)); + if let Some(list) = m.rsplit(':').next() { for u in list.split(',') { if !u.is_empty() { sudoers.insert(u.to_string()); } } } + } + let avni_grp: HashSet = sh("getent group avni").rsplit(':').next().unwrap_or("") + .split(',').filter(|u| !u.is_empty()).map(|u| u.to_string()).collect(); + let delta = ist_delta_min(); + let mut live: HashMap = HashMap::new(); + for l in sh("who").lines() { if let Some(u) = l.split_whitespace().next() { *live.entry(u.into()).or_insert(0) += 1; } } + let mut rows = vec![]; + for l in sh("getent passwd").lines() { + let p: Vec<&str> = l.split(':').collect(); + if p.len() < 7 { continue; } + let uid: i64 = p[2].parse().unwrap_or(-1); + if !(uid == 0 || uid >= 1000) || p[0] == "nobody" || p[6].ends_with("nologin") || p[6].ends_with("false") { continue; } + let ll = sh(&format!("lastlog -u {}", p[0])); + // lastlog tail is "... Mon DD HH:MM:SS +ZZZZ YYYY" — keep just "Mon DD HH:MM:SS" + IST + let last = ll.lines().nth(1).map(|l| { + if l.contains("Never") { return "Never".into(); } + let t: Vec<&str> = l.split_whitespace().collect(); + match t.iter().position(|x| x.matches(':').count() == 2 && x.split(':').next().map(|h| h.len() <= 2 && h.parse::().is_ok()).unwrap_or(false)) { + Some(i) if i >= 2 => with_ist(&format!("{} {} {}", t[i - 2], t[i - 1], t[i]), delta), + _ => String::new(), + } + }).unwrap_or_default(); + let locked = sh(&format!("passwd -S {}", p[0])).split_whitespace().nth(1).map(|s| s.starts_with('L')).unwrap_or(false); + rows.push(UserRow { user: p[0].into(), uid: p[2].into(), sudo: sudoers.contains(p[0]) || uid == 0, + live: *live.get(p[0]).unwrap_or(&0), locked, key: std::path::Path::new(&format!("{}/.ssh/authorized_keys", p[5])).exists(), + last, groups: sh(&format!("id -nG {}", p[0])), access: uid == 0 || avni_grp.contains(p[0]) }); + } + rows +} +fn kick_session(s: &Sess) -> String { + if s.me { return "refused: that is YOUR session".into(); } + sh(&format!("pkill -KILL -t {}", s.tty)); format!("kicked {}", s.tty) +} +// ----- user management (root is protected everywhere) ----- +fn grant_sudo(user: &str) -> String { + sh(&format!("usermod -aG sudo '{}'", user)); + format!("granted sudo to {}", user) +} +fn revoke_sudo(user: &str) -> String { + if user == "root" { return "refused: cannot revoke root's sudo".into(); } + for g in ["sudo", "admin", "wheel"] { sh(&format!("gpasswd -d '{}' {} 2>/dev/null", user, g)); } + format!("revoked sudo from {}", user) +} +// Deletes the ACCOUNT only — keeps /home and files (safer on this multi-tenant box). +fn del_user(user: &str) -> String { + if user == "root" { return "refused: cannot delete root".into(); } + let out = sh(&format!("userdel '{}' 2>&1", user)); + if out.trim().is_empty() { format!("deleted {} (home kept)", user) } else { format!("userdel: {}", out.trim()) } +} +fn valid_username(u: &str) -> bool { + !u.is_empty() && u.len() <= 32 + && u.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false) + && u.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') +} +fn safe_pw(p: &str) -> bool { + !p.is_empty() && p.len() <= 64 + && p.chars().all(|c| c.is_ascii_graphic() && !matches!(c, '\'' | '"' | '`' | '\\' | '$')) +} +// Create a Linux account (-m home, bash shell). Input: "username" or "username password". +fn create_user(input: &str) -> String { + let parts: Vec<&str> = input.trim().split_whitespace().collect(); + if parts.is_empty() { return "no username given".into(); } + let user = parts[0]; + if !valid_username(user) { return format!("invalid username '{}' (lowercase letter, then a-z 0-9 _ -)", user); } + if !sh(&format!("id -u '{}' 2>/dev/null", user)).trim().is_empty() { return format!("user {} already exists", user); } + let pw = parts.get(1).copied(); + if let Some(p) = pw { if !safe_pw(p) { return "password has unsupported characters — create without one, then set via passwd".into(); } } + let out = sh(&format!("useradd -m -s /bin/bash '{}' 2>&1", user)); + if !out.trim().is_empty() { return format!("useradd: {}", out.trim()); } + match pw { + Some(p) => { sh(&format!("printf '%s:%s' '{}' '{}' | chpasswd", user, p)); format!("created {} with password (/home/{})", user, user) } + None => format!("created {} — no password set yet (locked; set with passwd)", user), + } +} +// Set a user's Linux LOGIN password (the one used for SSH password-auth + sudo). +// Runs as root, so no old password is needed. Same injection-safe quoting as create_user. +fn set_passwd(user: &str, pw: &str) -> String { + if sh(&format!("id -u '{}' 2>/dev/null", user)).trim().is_empty() { return format!("no such user: {}", user); } + if pw.len() < 8 { return "refused: use at least 8 characters".into(); } + if !safe_pw(pw) { return "password has unsupported characters (no spaces or ' \" ` \\ $)".into(); } + let out = sh(&format!("printf '%s:%s' '{}' '{}' | chpasswd 2>&1", user, pw)); + if out.trim().is_empty() { format!("login password updated for {}", user) } else { format!("chpasswd: {}", out.trim()) } +} +fn is_sudoer(user: &str) -> bool { + if user == "root" { return true; } + sh(&format!("id -nG '{}' 2>/dev/null", user)).split_whitespace().any(|g| g == "sudo" || g == "admin" || g == "wheel") +} +// All activity for one user — purely SYSTEM-LEVEL metadata (logins, sudo, processes). +// Never reads anyone's $HOME / shell history (respects tenant privacy). +fn user_activity(user: &str) -> String { + let delta = ist_delta_min(); + let ist_lines = |s: &str| s.lines().map(|l| with_ist(l, delta)).collect::>().join("\n"); + let shell = sh(&format!("getent passwd '{}' | cut -d: -f7", user)); + let home = sh(&format!("getent passwd '{}' | cut -d: -f6", user)); + let pwstat = sh(&format!("passwd -S '{}' 2>/dev/null", user)); + let groups = sh(&format!("id -nG '{}' 2>/dev/null", user)); + let mut out = format!("now {} · {} IST\nuid/gid {}\nshell {}\nhome {}\ngroups {}\npasswd {}\nadmin {} avni-access {}", + sh("date '+%Y-%m-%d %H:%M:%S %Z'"), sh("TZ=Asia/Kolkata date '+%H:%M'"), + sh(&format!("id -u '{}' 2>/dev/null", user)), shell, home, groups, pwstat, is_sudoer(user), + sh("getent group avni").rsplit(':').next().unwrap_or("").split(',').any(|u| u == user) || sh(&format!("id -u '{}'", user)).trim() == "0"); + let live = sh(&format!("who | grep -c '^{} '", user)); + out.push_str(&format!("\n\n— live sessions ({}) · times show IST —", live.trim())); + let who = sh(&format!("who | awk '$1==\"{}\"'", user)); + out.push_str(&format!("\n{}", if who.trim().is_empty() { " (not logged in now)".into() } else { ist_lines(&who) })); + out.push_str("\n\n— recent logins (last) —\n"); + let last = sh(&format!("last -i -n 8 '{}' | head -n 8", user)); + out.push_str(if last.trim().is_empty() { " (none recorded)".into() } else { ist_lines(last.trim_end()) }.as_str()); + let lastb = sh(&format!("lastb -i -n 5 '{}' 2>/dev/null | head -n 5", user)); + if !lastb.trim().is_empty() && !lastb.contains("Permission") { + out.push_str("\n\n— recent FAILED logins (lastb) —\n"); out.push_str(&ist_lines(lastb.trim_end())); + } + let sudo = sh(&format!("grep -h ' sudo:.*{} :' /var/log/auth.log /var/log/auth.log.1 2>/dev/null | grep -o 'COMMAND=.*' | tail -n 8", user)); + out.push_str("\n\n— recent sudo commands —\n"); + out.push_str(if sudo.trim().is_empty() { " (none)" } else { sudo.trim_end() }); + let nproc = sh(&format!("ps -u '{}' --no-headers 2>/dev/null | wc -l", user)); + let ps = sh(&format!("ps -u '{}' -o pid,pcpu,pmem,etime,comm --sort=-pcpu --no-headers 2>/dev/null | head -n 6", user)); + out.push_str(&format!("\n\n— running processes ({}) —\n", nproc.trim())); + out.push_str(if ps.trim().is_empty() { " (none)" } else { ps.trim_end() }); + out +} +// ----- avni tool access delegation (root + 'avni' group). Granted users auto-elevate via sudo. ----- +fn grant_access(user: &str) -> String { + if user == "root" { return "root already has access".into(); } + if !is_sudoer(user) { return format!("refused: make {} an admin first (Users tab → G grant sudo)", user); } + sh("groupadd -f avni"); + sh(&format!("usermod -aG avni '{}'", user)); + format!("granted avni access to {} — they can now run: avni", user) +} +fn revoke_access(user: &str) -> String { + if user == "root" { return "refused: root always has access".into(); } + sh(&format!("gpasswd -d '{}' avni 2>/dev/null", user)); + format!("revoked avni access from {}", user) +} +// Root-gate: if not root, only 'avni' group members may run it — and they re-exec via sudo. +// Returns Some(exit_code) if main() should exit now (refused or finished an elevated re-exec). +fn gate_and_elevate() -> Option { + if sh("id -u").trim() == "0" { return None; } // already root → proceed + let me = sh("id -un"); + if !sh("id -nG").split_whitespace().any(|g| g == "avni") { + eprintln!("avni: access is restricted to root and authorised admins."); + eprintln!("Ask root to grant you access (avni → Settings tab). You ({}) are not authorised.", me.trim()); + return Some(1); + } + // authorised non-root user → elevate via sudo using the canonical install path (matches sudoers) + let args: Vec = std::env::args().skip(1).collect(); + match Command::new("sudo").arg("/usr/local/bin/avni").args(&args).status() { + Ok(s) => Some(s.code().unwrap_or(0)), + Err(_) => { eprintln!("avni: failed to elevate via sudo — is sudo installed?"); Some(1) } + } +} + +// ----------------------------------------------------------------- App +enum Input { None, Blacklist, Whitelist, NewUser, SetPasswd } +#[derive(Default)] +struct Snapshot { + ov: Overview, svcs: Vec, doms: Vec<(String, String)>, apps: Vec, reg: HashMap, + sess: Vec, users: Vec, ssh: SshCfg, + t_hourly: Vec<(String, u64)>, t_daily: Vec<(String, u64)>, t_today: String, iface: String, + bl_count: usize, ban_count: usize, wl_count: usize, +} +// Gather a full data snapshot. Runs in a BACKGROUND thread so the UI never blocks, +// and FANS OUT the independent fetches across threads so wall-time ≈ the slowest +// single part (~2 s docker) instead of the sum of all of them (~5-11 s). +fn gather() -> Snapshot { + let mut s = Snapshot::default(); + std::thread::scope(|sc| { + let h_ov = sc.spawn(overview); + let h_dock = sc.spawn(docker_snapshot); + let h_reg = sc.spawn(reg_update); + let h_sess = sc.spawn(sessions); + let h_users = sc.spawn(users_list); + let h_ssh = sc.spawn(ssh_cfg); + let h_traf = sc.spawn(|| { let i = primary_iface(); (vnstat_full("h", &i), vnstat_full("d", &i), vnstat_today(), i) }); + s.ov = h_ov.join().unwrap(); + let (svcs, doms, apps) = h_dock.join().unwrap(); s.svcs = svcs; s.doms = doms; s.apps = apps; + s.reg = h_reg.join().unwrap(); + s.sess = h_sess.join().unwrap(); + s.users = h_users.join().unwrap(); + s.ssh = h_ssh.join().unwrap(); + let (th, td, tt, ifc) = h_traf.join().unwrap(); s.t_hourly = th; s.t_daily = td; s.t_today = tt; s.iface = ifc; + }); + // counts derive from the registry (reg_update already flags banned/blacklisted/ + // whitelisted on every such key) — no extra fail2ban/firewall forks needed. + s.ssh.attackers = s.reg.values().filter(|e| e.attempts > 0).count(); + s.ban_count = s.reg.values().filter(|e| e.banned).count(); + s.bl_count = s.reg.values().filter(|e| e.blacklisted).count(); + s.wl_count = s.reg.values().filter(|e| e.whitelisted).count(); + s +} + +struct App { + tab: usize, sub: usize, tw: usize, + sel: [usize; 9], + input: Input, buf: String, status: String, pw_user: String, + ov: Overview, svcs: Vec, doms: Vec<(String, String)>, apps: Vec, reg: HashMap, + sess: Vec, users: Vec, ssh: SshCfg, net: Vec<(String, String)>, + t_hourly: Vec<(String, u64)>, t_daily: Vec<(String, u64)>, t_today: String, iface: String, + bl_count: usize, ban_count: usize, wl_count: usize, + last: Instant, popup: Option<(String, String)>, + confirm: Option, // pending destructive action: username to delete + rx: Option>, loading: bool, + ready: bool, boot: Instant, // ready=first snapshot applied; boot=splash spinner clock +} +impl App { + fn blank() -> Self { + App { tab: 0, sub: 0, tw: 1, sel: [0; 9], input: Input::None, buf: String::new(), pw_user: String::new(), + status: "loading… · keys shown in footer".into(), + ov: Overview::default(), svcs: vec![], doms: vec![], apps: vec![], reg: HashMap::new(), sess: vec![], users: vec![], + ssh: SshCfg::default(), net: vec![], t_hourly: vec![], t_daily: vec![], t_today: String::new(), iface: String::new(), + bl_count: 0, ban_count: 0, wl_count: 0, + last: Instant::now() - Duration::from_secs(99), popup: None, confirm: None, rx: None, loading: false, + ready: false, boot: Instant::now() } + } + fn new() -> Self { + let mut a = App::blank(); + a.kick(); // start first load in the background; UI paints immediately + a + } + // spawn a background gather if one isn't already running + fn kick(&mut self) { + if self.rx.is_some() { return; } + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { let _ = tx.send(gather()); }); + self.rx = Some(rx); self.loading = true; + } + // non-blocking: apply a finished snapshot if ready + fn poll_data(&mut self) { + let mut done = false; + if let Some(rx) = &self.rx { + if let Ok(s) = rx.try_recv() { + self.ov = s.ov; self.svcs = s.svcs; self.doms = s.doms; self.apps = s.apps; self.reg = s.reg; + self.sess = s.sess; self.users = s.users; self.ssh = s.ssh; + self.t_hourly = s.t_hourly; self.t_daily = s.t_daily; self.t_today = s.t_today; self.iface = s.iface; + self.bl_count = s.bl_count; self.ban_count = s.ban_count; self.wl_count = s.wl_count; + self.last = Instant::now(); done = true; + } + } + if done { self.rx = None; self.loading = false; self.ready = true; } + } + fn rows_security(&self) -> Vec<(String, IpRec)> { + if self.sub == 6 { let mut v: Vec<_> = self.reg.iter().map(|(k, r)| (k.clone(), r.clone())).collect(); v.sort_by(|a, b| b.1.attempts.cmp(&a.1.attempts)); v } + else { reg_filter(&self.reg, self.sub) } + } + fn sel_mut(&mut self) -> &mut usize { &mut self.sel[self.tab] } + // optimistic in-memory edits so an action shows INSTANTLY (the background + // re-gather later reconciles with the real system state). + fn set_user(&mut self, name: &str, f: F) { + if let Some(r) = self.users.iter_mut().find(|x| x.user == name) { f(r); } + } + fn set_ip(&mut self, ip: &str, f: F) { + let e = self.reg.entry(ip.to_string()).or_default(); f(e); + } +} + +fn status_of(e: &IpRec) -> &'static str { + if e.whitelisted { "WHITELIST" } else if e.blacklisted { "BLACKLIST" } else if e.banned { "BANNED" } + else if e.logins > 0 { "login" } else { "seen" } +} + +// ----------------------------------------------------------------- main +fn main() -> io::Result<()> { + // root-only tool; authorised ('avni' group) non-root users are re-exec'd via sudo. + if let Some(code) = gate_and_elevate() { std::process::exit(code); } + let args: Vec = std::env::args().skip(1).collect(); + if !args.is_empty() { return cli(&args); } + enable_raw_mode()?; + let mut out = io::stdout(); + execute!(out, EnterAlternateScreen)?; + let mut term = Terminal::new(CrosstermBackend::new(out))?; + let mut app = App::new(); + let res = run(&mut term, &mut app); + disable_raw_mode()?; + execute!(term.backend_mut(), LeaveAlternateScreen)?; + term.show_cursor()?; + res +} + +fn run(term: &mut Terminal, app: &mut App) -> io::Result<()> { + loop { + app.poll_data(); // pick up finished background snapshot (non-blocking) + term.draw(|f| ui(f, app))?; + let poll_ms = if app.ready { 300 } else { 80 }; // tick fast during the loading splash + if event::poll(Duration::from_millis(poll_ms))? { + if let Event::Key(k) = event::read()? { + if k.kind != KeyEventKind::Press { continue; } + if app.popup.is_some() { app.popup = None; continue; } + // destructive-action confirm: only y/Y proceeds, anything else cancels + if let Some(user) = app.confirm.clone() { + match k.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { app.status = del_user(&user); app.kick(); } + _ => { app.status = "delete cancelled".into(); } + } + app.confirm = None; continue; + } + if !matches!(app.input, Input::None) { + match k.code { + KeyCode::Enter => { + if !app.buf.is_empty() { + let val = app.buf.trim().to_string(); + match app.input { + Input::Whitelist => { app.status = whitelist_add(&val); app.wl_count += 1; if let Some(ip) = valid_ip(&val) { app.set_ip(&ip, |e| { e.whitelisted = true; e.banned = false; e.blacklisted = false; }); } } + Input::NewUser => { app.status = create_user(&val); } + Input::SetPasswd => { app.status = set_passwd(&app.pw_user, &val); } + _ => { app.status = blacklist_add(&val); app.bl_count += 1; if let Some(ip) = valid_ip(&val) { app.set_ip(&ip, |e| e.blacklisted = true); } } + } + app.kick(); // background reconcile; the optimistic edit shows now + } + app.input = Input::None; app.buf.clear(); + } + KeyCode::Esc => { app.input = Input::None; app.buf.clear(); app.status = "cancelled".into(); } + KeyCode::Backspace => { app.buf.pop(); } + KeyCode::Char(c) if c.is_ascii() && !c.is_control() => app.buf.push(c), + _ => {} + } + continue; + } + match k.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Right | KeyCode::Tab => { app.tab = (app.tab + 1) % TABS.len(); } + KeyCode::Left | KeyCode::BackTab => { app.tab = (app.tab + TABS.len() - 1) % TABS.len(); } + KeyCode::Char(c @ '1'..='9') => { let i = c as usize - '1' as usize; if i < TABS.len() { app.tab = i; } } + KeyCode::Char('r') => { app.status = "refreshing…".into(); app.kick(); } + // On Security tab, ← → also switch SUB-TABS (in addition to [ ]); elsewhere they switch main tabs handled above + KeyCode::Char('[') if app.tab == 4 => { app.sub = (app.sub + SUBS.len() - 1) % SUBS.len(); app.sel[4] = 0; } + KeyCode::Char(']') if app.tab == 4 => { app.sub = (app.sub + 1) % SUBS.len(); app.sel[4] = 0; } + KeyCode::Down | KeyCode::Char('j') => { if app.tab == 3 { app.tw = (app.tw + 1) % 3; } else { *app.sel_mut() += 1; } } + KeyCode::Up | KeyCode::Char('k') => { if app.tab == 3 { app.tw = (app.tw + 2) % 3; } else { let s = app.sel_mut(); if *s > 0 { *s -= 1; } } } + _ => action(app, k.code, k.modifiers), + } + clamp(app); + } + } + // auto-refresh in the background — never while typing in a modal or reading a popup + if matches!(app.input, Input::None) && app.popup.is_none() && app.confirm.is_none() && app.rx.is_none() && app.last.elapsed() > Duration::from_secs(10) { + app.kick(); + } + } +} + +fn clamp(app: &mut App) { + let n = match app.tab { 1 => app.svcs.len(), 2 => app.doms.len(), 4 => app.rows_security().len(), 5 => app.sess.len(), 6 | 7 => app.users.len(), 8 => app.apps.len(), _ => 0 }; + let s = &mut app.sel[app.tab]; + if n == 0 { *s = 0; } else if *s >= n { *s = n - 1; } +} + +fn action(app: &mut App, code: KeyCode, _m: KeyModifiers) { + match app.tab { + 1 => { // Services + if let Some(s) = app.svcs.get(app.sel[1]).cloned() { + match code { + KeyCode::Char('R') => { app.status = format!("restarting {}…", s.name); sh(&format!("docker restart {}", s.name)); app.kick(); app.status = format!("restarted {}", s.name); } + KeyCode::Char('x') => { sh(&format!("docker stop {}", s.name)); app.kick(); app.status = format!("stopped {}", s.name); } + KeyCode::Char('s') => { sh(&format!("docker start {}", s.name)); app.kick(); app.status = format!("started {}", s.name); } + KeyCode::Char('l') => { app.popup = Some((format!("logs {}", s.name), sh(&format!("docker logs --tail 60 {}", s.name)))); } + _ => {} + } + } + } + 2 => { if let Some((d, c)) = app.doms.get(app.sel[2]).cloned() { + if let KeyCode::Enter = code { + let code = sh(&format!("curl -s -m8 -o /dev/null -w %{{http_code}} https://{}/", d)); + let cert = sh(&format!("echo | timeout 6 openssl s_client -servername {} -connect {}:443 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null", d, d)); + app.popup = Some((format!("domain {}", d), format!("backend {}\nHTTP {}\n{}", c, code, cert))); + } + }} + 4 => { // Security + let rows = app.rows_security(); + let ip = rows.get(app.sel[4]).map(|(i, _)| i.clone()); + match code { + KeyCode::Char('a') => { app.input = Input::Blacklist; app.buf.clear(); app.status = "BLACKLIST: type IP/CIDR/domain then Enter (Esc cancels)".into(); } + KeyCode::Char('A') => { app.input = Input::Whitelist; app.buf.clear(); app.status = "WHITELIST: type IP/CIDR/domain then Enter (Esc cancels)".into(); } + KeyCode::Char('b') => { if let Some(ip) = ip { app.status = ban(&ip); app.set_ip(&ip, |e| e.banned = true); app.ban_count += 1; app.kick(); } } + KeyCode::Char('B') => { if let Some(ip) = ip { app.status = blacklist_add(&ip); app.set_ip(&ip, |e| e.blacklisted = true); app.bl_count += 1; app.kick(); } } + KeyCode::Char('W') => { if let Some(ip) = ip { app.status = whitelist_add(&ip); app.set_ip(&ip, |e| { e.whitelisted = true; e.banned = false; e.blacklisted = false; }); app.wl_count += 1; app.kick(); } } + KeyCode::Char('u') => { if let Some(ip) = ip { + if app.reg.get(&ip).map(|e| e.whitelisted).unwrap_or(false) { app.status = whitelist_del(&ip); app.set_ip(&ip, |e| e.whitelisted = false); app.wl_count = app.wl_count.saturating_sub(1); } + else { app.status = unban(&ip); app.set_ip(&ip, |e| { e.banned = false; e.blacklisted = false; }); app.ban_count = app.ban_count.saturating_sub(1); } + app.kick(); } } + KeyCode::Char('w') => { if let Some(ip) = ip { app.popup = Some((format!("whois {}", ip), whois_ip(&ip))); } } + KeyCode::Enter => { if let Some(ip) = ip { if let Some(e) = app.reg.get(&ip).cloned() { + let d = ist_delta_min(); + app.popup = Some((format!("IP intel {}", ip), format!( + "country {}\nattempts {} logins {}\nfirst seen {}\nlast seen {}\nbanned {} blacklisted {} whitelisted {}\nlast user {}\norg {}", + e.country, e.attempts, e.logins, with_ist(&e.first, d), with_ist(&e.last, d), e.banned, e.blacklisted, e.whitelisted, e.last_user, whois_ip(&ip).replace('\n', " · ")))); + }}} + KeyCode::Char('g') => { app.popup = Some(("Permanent blacklist".into(), blacklist_list().iter().map(|x| format!("{} {}", x, geo(x.split('/').next().unwrap_or(x)))).collect::>().join("\n"))); } + _ => {} + } + } + 5 => { if let Some(s) = app.sess.get(app.sel[5]).cloned() { + match code { + KeyCode::Char('k') => { app.status = kick_session(&s); app.kick(); } + KeyCode::Char('K') => { let r = kick_session(&s); let b = blacklist_add(&s.from); app.status = format!("{} + {}", r, b); app.kick(); } + _ => {} + } + }} + 6 => { + // 'N' (new user) doesn't need a selected row — handle it first + if let KeyCode::Char('N') = code { + app.input = Input::NewUser; app.buf.clear(); + app.status = "NEW USER: type username (or username password) then Enter".into(); + return; + } + if let Some(u) = app.users.get(app.sel[6]).cloned() { + let is_root = u.uid == "0" || u.user == "root"; + match code { + KeyCode::Char('L') => { if is_root { app.status = "refused: cannot lock root".into(); } else { sh(&format!("usermod -L {}", u.user)); app.set_user(&u.user, |r| r.locked = true); app.status = format!("locked {}", u.user); app.kick(); } } + KeyCode::Char('U') => { sh(&format!("usermod -U {}", u.user)); app.set_user(&u.user, |r| r.locked = false); app.status = format!("unlocked {}", u.user); app.kick(); } + KeyCode::Char('G') => { app.status = grant_sudo(&u.user); app.set_user(&u.user, |r| r.sudo = true); app.kick(); } + KeyCode::Char('g') => { if is_root { app.status = "refused: cannot revoke root's sudo".into(); } else { app.status = revoke_sudo(&u.user); app.set_user(&u.user, |r| { r.sudo = false; r.access = false; }); app.kick(); } } + KeyCode::Char('D') => { if is_root { app.status = "refused: cannot delete root".into(); } else { app.confirm = Some(u.user.clone()); } } + KeyCode::Char('P') => { app.input = Input::SetPasswd; app.pw_user = u.user.clone(); app.buf.clear(); app.status = format!("SET LOGIN PASSWORD for {} — type new password then Enter (Esc cancels)", u.user); } + KeyCode::Enter => { app.popup = Some((format!("activity · {}", u.user), user_activity(&u.user))); } + _ => {} + } + }} + 7 => { if let Some(u) = app.users.get(app.sel[7]).cloned() { + match code { + KeyCode::Char('g') => { app.status = grant_access(&u.user); if !app.status.starts_with("refused") { app.set_user(&u.user, |r| r.access = true); } app.kick(); } + KeyCode::Char('x') | KeyCode::Char('u') => { app.status = revoke_access(&u.user); if !app.status.starts_with("refused") { app.set_user(&u.user, |r| r.access = false); } app.kick(); } + KeyCode::Enter => { app.popup = Some((format!("activity · {}", u.user), user_activity(&u.user))); } + _ => {} + } + }} + 8 => { if let Some(a) = app.apps.get(app.sel[8]).cloned() { + match code { + KeyCode::Char('s') => { sh(&format!("docker start {}", a.name)); if let Some(r) = app.apps.iter_mut().find(|x| x.name == a.name) { r.status = "Up (starting)".into(); } app.status = format!("enabled {}", a.name); app.kick(); } + KeyCode::Char('x') => { sh(&format!("docker stop {}", a.name)); if let Some(r) = app.apps.iter_mut().find(|x| x.name == a.name) { r.status = "Exited (disabled)".into(); } app.status = format!("disabled {}", a.name); app.kick(); } + KeyCode::Char('R') => { app.status = format!("restarting {}…", a.name); sh(&format!("docker restart {}", a.name)); app.status = format!("restarted {}", a.name); app.kick(); } + KeyCode::Char('l') => { app.popup = Some((format!("logs {}", a.name), sh(&format!("docker logs --tail 60 {}", a.name)))); } + KeyCode::Enter => { + let first = a.domain.split(',').next().unwrap_or("").to_string(); + let (code, cert) = if first.is_empty() { ("-".into(), String::new()) } else { + (sh(&format!("curl -s -m8 -o /dev/null -w %{{http_code}} https://{}/", first)), + sh(&format!("echo | timeout 6 openssl s_client -servername {} -connect {}:443 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null", first, first))) }; + app.popup = Some((format!("application · {}", a.name), format!( + "container {}\nstatus {}\nsubdomain {}\nint port {}\nhost ports {}\ncompose dir {}\nHTTPS code {}\n{}", + a.name, a.status, + if a.domain.is_empty() { "-" } else { &a.domain }, + if a.port.is_empty() { "-" } else { &a.port }, + if a.hostports.is_empty() { "-" } else { &a.hostports }, + if a.compose.is_empty() { "-" } else { &a.compose }, code, cert.trim()))); + } + _ => {} + } + }} + _ => {} + } +} + +// ----------------------------------------------------------------- UI +fn ui(f: &mut Frame, app: &mut App) { + let area = f.size(); + if !app.ready { splash(f, area, app); return; } // loading screen until first snapshot lands + let chunks = Layout::default().direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), Constraint::Length(1)]).split(area); + // header tabs + let titles: Vec = TABS.iter().enumerate().map(|(i, t)| Line::from(format!(" {}.{} ", i + 1, t))).collect(); + let tabs = Tabs::new(titles).select(app.tab) + .block(Block::default().borders(Borders::ALL).title(Span::styled(" AVNI CLOUD · control center ", Style::default().fg(INK).bg(CREAM).add_modifier(Modifier::BOLD))).border_style(Style::default().fg(CREAM))) + .highlight_style(Style::default().fg(INK).bg(CREAM).add_modifier(Modifier::BOLD)).divider("·"); + f.render_widget(tabs, chunks[0]); + match app.tab { + 0 => ov_tab(f, chunks[1], app), + 1 => svc_tab(f, chunks[1], app), + 2 => dom_tab(f, chunks[1], app), + 3 => traffic_tab(f, chunks[1], app), + 4 => sec_tab(f, chunks[1], app), + 5 => ssh_tab(f, chunks[1], app), + 6 => users_tab(f, chunks[1], app), + 7 => settings_tab(f, chunks[1], app), + 8 => apps_tab(f, chunks[1], app), + _ => {} + } + // footer line 1: status message (ink-on-cream pill) + global keys + let status_line = Line::from(vec![ + Span::styled(format!(" {} ", app.status), Style::default().fg(INK).bg(CREAM).add_modifier(Modifier::BOLD)), + ]); + f.render_widget(Paragraph::new(status_line), chunks[2]); + // footer line 2: color-coded key chips for THIS tab + global + let mut keys: Vec<(&str, &str)> = match app.tab { + 0 => vec![], + 1 => vec![("s","start"),("x","stop"),("R","restart"),("l","logs")], + 2 => vec![("↵","detail")], + 3 => vec![("↑↓","window")], + 4 => vec![("[ ]","sub-tab"),("a","+blacklist"),("A","+whitelist"),("b","ban"),("B","blacklist"),("W","whitelist"),("u","un-do"),("w","whois"),("↵","detail")], + 5 => vec![("k","kick"),("K","kick+block")], + 6 => vec![("↵","activity"),("N","new user"),("P","passwd"),("G","+sudo"),("g","-sudo"),("L","lock"),("U","unlock"),("D","delete")], + 7 => vec![("g","grant avni"),("x","revoke"),("↵","activity")], + 8 => vec![("s","enable"),("x","disable"),("R","restart"),("l","logs"),("↵","details")], + _ => vec![], + }; + keys.extend_from_slice(&[("1-9/⇄","tabs"),("↑↓","move"),("r","refresh"),("q","quit")]); + let mut spans: Vec = vec![Span::raw(" ")]; + for (k, label) in keys { + spans.push(Span::styled(format!(" {} ", k), Style::default().fg(INK).bg(CREAM).add_modifier(Modifier::BOLD))); + spans.push(Span::styled(format!(" {}", label), Style::default().fg(CREAM))); + spans.push(Span::styled(" ", Style::default())); + } + f.render_widget(Paragraph::new(Line::from(spans)), chunks[3]); + // input modal (fixed size so it never collapses) + if !matches!(app.input, Input::None) { + let pw_note = format!("Sets {}'s Linux login / SSH password. Min 8 chars; no spaces or ' \" ` \\ $.", app.pw_user); + let (title, accent, note, example) = match app.input { + Input::Whitelist => (" ADD TO WHITELIST ", Color::Green, "Whitelisted IPs can NEVER be banned or blacklisted.", + " e.g. 198.51.100.23 · 10.0.0.0/24 · trusted.host.com"), + Input::NewUser => (" CREATE NEW USER ", Color::Cyan, "Creates a Linux account with its own /home and a bash shell.", + " e.g. alice · alice S3cret-pw (username [password])"), + Input::SetPasswd => (" SET LOGIN PASSWORD ", Color::Magenta, pw_note.as_str(), + " used for SSH password login and sudo · input is hidden"), + _ => (" ADD TO BLACKLIST ", Color::Red, "Blacklisted IPs are dropped at the firewall (host + Docker), permanently.", + " e.g. 198.51.100.23 · 10.0.0.0/24 · badhost.com"), + }; + let shown = if matches!(app.input, Input::SetPasswd) { "•".repeat(app.buf.chars().count()) } else { app.buf.clone() }; + let r = centered_abs(66, 8, area); + f.render_widget(Clear, r); + let block = Block::default().borders(Borders::ALL) + .title(Span::styled(title, Style::default().fg(INK).bg(accent).add_modifier(Modifier::BOLD))) + .border_style(Style::default().fg(accent)); + let inner = block.inner(r); + f.render_widget(block, r); + let lines = vec![ + Line::from(Span::styled(note, Style::default().fg(Color::Gray))), + Line::from(""), + Line::from(vec![ + Span::styled(" ❯ ", Style::default().fg(accent).add_modifier(Modifier::BOLD)), + Span::styled(shown, Style::default().fg(CREAM).add_modifier(Modifier::BOLD)), + Span::styled("▌", Style::default().fg(accent)), + ]), + Line::from(Span::styled(example, Style::default().fg(Color::DarkGray))), + Line::from(""), + Line::from(vec![ + Span::styled(" Enter ", Style::default().fg(INK).bg(CREAM).add_modifier(Modifier::BOLD)), + Span::styled(" confirm ", Style::default().fg(CREAM)), + Span::styled(" Esc ", Style::default().fg(INK).bg(CREAM).add_modifier(Modifier::BOLD)), + Span::styled(" cancel", Style::default().fg(CREAM)), + ]), + ]; + f.render_widget(Paragraph::new(lines), inner); + } + // delete-user confirmation (destructive → explicit y/N) + if let Some(user) = app.confirm.clone() { + let r = centered_abs(60, 9, area); + f.render_widget(Clear, r); + let block = Block::default().borders(Borders::ALL) + .title(Span::styled(" DELETE USER ", Style::default().fg(INK).bg(Color::Red).add_modifier(Modifier::BOLD))) + .border_style(Style::default().fg(Color::Red)); + let inner = block.inner(r); + f.render_widget(block, r); + let lines = vec![ + Line::from(vec![ + Span::styled(" Delete account ", Style::default().fg(CREAM)), + Span::styled(user.clone(), Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled(" ?", Style::default().fg(CREAM)), + ]), + Line::from(""), + Line::from(Span::styled(" The /home directory and files are KEPT (account only).", Style::default().fg(Color::Gray))), + Line::from(""), + Line::from(vec![ + Span::styled(" y ", Style::default().fg(INK).bg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled(" yes, delete ", Style::default().fg(CREAM)), + Span::styled(" any other key ", Style::default().fg(INK).bg(CREAM).add_modifier(Modifier::BOLD)), + Span::styled(" cancel", Style::default().fg(CREAM)), + ]), + ]; + f.render_widget(Paragraph::new(lines), inner); + } + if let Some((title, body)) = app.popup.clone() { + let h = (body.lines().count() as u16 + 4).min(area.height.saturating_sub(2)).max(6); + let r = centered_abs(78, h, area); + f.render_widget(Clear, r); + f.render_widget(Paragraph::new(body).wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL) + .title(Span::styled(format!(" {} ", title), Style::default().fg(INK).bg(CREAM).add_modifier(Modifier::BOLD))) + .title(block::Title::from(Span::styled(" any key to close ", Style::default().fg(Color::Gray))).position(block::Position::Bottom).alignment(Alignment::Right)) + .border_style(Style::default().fg(CREAM))), r); + } +} + +fn gauge(f: &mut Frame, area: Rect, label: &str, ratio: f64, text: String) { + // one row: fixed-width label on the left, bare gauge bar on the right (no block, + // so the single row is the bar itself — fixes the "label only, no bar" issue). + let cols = Layout::default().direction(Direction::Horizontal) + .constraints([Constraint::Length(9), Constraint::Min(10)]).split(area); + f.render_widget(Paragraph::new(Span::styled(label, Style::default().fg(CREAM).add_modifier(Modifier::BOLD))), cols[0]); + let col = if ratio < 0.7 { Color::Green } else if ratio < 0.9 { Color::Yellow } else { Color::Red }; + let g = LineGauge::default().gauge_style(Style::default().fg(col).bg(Color::DarkGray)) + .ratio(ratio.clamp(0.0, 1.0)).label(text); + f.render_widget(g, cols[1]); +} + +fn ov_tab(f: &mut Frame, area: Rect, app: &App) { + let o = &app.ov; + let rows = Layout::default().direction(Direction::Vertical).constraints([ + Constraint::Length(5), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(2), Constraint::Length(7), Constraint::Min(0)]).split(area); + let info = format!("Host {} {}\nOS {} · {}\nUptime {}\nTime {} · {} (your time)\nLoad {:.2} {:.2} {:.2} · {} cpus", + o.host, o.ip, o.os, o.kernel, o.uptime, o.time, o.ist, o.load[0], o.load[1], o.load[2], o.ncpu); + f.render_widget(Paragraph::new(info).style(Style::default().fg(Color::White)), rows[0]); + gauge(f, rows[1], "Load ", o.load[0] / o.ncpu.max(1.0), format!("{:.2}/{}", o.load[0], o.ncpu)); + gauge(f, rows[2], "Memory", o.mem_used as f64 / o.mem_total.max(1) as f64, format!("{}/{} MB", o.mem_used, o.mem_total)); + gauge(f, rows[3], "Disk ", o.disk_pct as f64 / 100.0, format!("{}% ({}/{})", o.disk_pct, o.disk_used, o.disk_size)); + let up = app.svcs.iter().filter(|s| s.status.starts_with("Up")).count(); + let bad: Vec<&str> = app.svcs.iter().filter(|s| s.status.contains("unhealthy") || (!s.status.starts_with("Up") && !s.status.starts_with("Exited (0)"))).map(|s| s.name.as_str()).collect(); + let line = format!("Services {}/{} up Domains {} Banned {} Blacklist {} Tracked IPs {}", + up, app.svcs.len(), app.doms.len(), app.ban_count, app.bl_count, app.reg.len()); + f.render_widget(Paragraph::new(line).style(Style::default().fg(CREAM)), rows[4]); + let mut att: Vec = app.reg.values().filter(|e| e.attempts > 0).map(|e| e.attempts).collect(); + att.sort_by(|a, b| b.cmp(a)); att.truncate(40); + let spk = Sparkline::default().block(Block::default().title(Span::styled(format!("Attack pressure (top IPs) · {} problem svc: {}", bad.len(), bad.join(", ")), Style::default().fg(Color::Red)))).data(&att).style(Style::default().fg(Color::Red)); + f.render_widget(spk, rows[5]); +} + +fn table<'a>(header: Vec<&'a str>, rows: Vec>, widths: Vec, sel: usize, title: String) -> (Table<'a>, TableState) { + let mut st = TableState::default(); st.select(Some(sel)); + let t = Table::new(rows, widths) + .header(Row::new(header).style(Style::default().fg(CREAM).add_modifier(Modifier::BOLD))) + .block(Block::default().borders(Borders::ALL).title(title).border_style(Style::default().fg(CREAM))) + .highlight_style(Style::default().fg(INK).bg(CREAM)).highlight_symbol("» "); + (t, st) +} + +fn svc_tab(f: &mut Frame, area: Rect, app: &mut App) { + let rows: Vec = app.svcs.iter().map(|s| { + let dot = if s.status.starts_with("Up") && !s.status.contains("unhealthy") { Span::styled("●", Style::default().fg(Color::Green)) } + else if s.status.contains("unhealthy") { Span::styled("●", Style::default().fg(Color::Yellow)) } + else { Span::styled("○", Style::default().fg(Color::Red)) }; + Row::new(vec![Cell::from(dot), Cell::from(s.name.clone()), Cell::from(s.status.clone()), Cell::from(s.domain.clone())]) + }).collect(); + let (t, mut st) = table(vec!["", "NAME", "STATUS", "DOMAIN"], + rows, vec![Constraint::Length(1), Constraint::Length(34), Constraint::Length(26), Constraint::Min(10)], app.sel[1], format!(" Services ({}) ", app.svcs.len())); + f.render_stateful_widget(t, area, &mut st); +} + +fn dom_tab(f: &mut Frame, area: Rect, app: &mut App) { + let rows: Vec = app.doms.iter().map(|(d, c)| Row::new(vec![Cell::from(d.clone()), Cell::from(c.clone())])).collect(); + let (t, mut st) = table(vec!["DOMAIN", "BACKEND"], rows, vec![Constraint::Length(36), Constraint::Min(10)], app.sel[2], format!(" Domains ({}) — Enter for cert/HTTP ", app.doms.len())); + f.render_stateful_widget(t, area, &mut st); +} + +fn traffic_tab(f: &mut Frame, area: Rect, app: &mut App) { + let win = ["24h", "7d", "30d"][app.tw]; + let rows = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(2), Constraint::Min(6)]).split(area); + // window picker + cached today line (no forking on render) + let picker: Vec = ["24h", "7d", "30d"].iter().enumerate().map(|(i, w)| { + let st = if i == app.tw { Style::default().fg(INK).bg(CREAM).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Gray) }; + Span::styled(format!(" {} ", w), st) + }).flat_map(|s| vec![s, Span::raw(" ")]).collect(); + let mut head = vec![Span::styled("↑↓ ", Style::default().fg(CREAM).add_modifier(Modifier::BOLD))]; + head.extend(picker); + head.push(Span::styled(format!(" iface {} {}", if app.iface.is_empty() { "…" } else { &app.iface }, app.t_today), Style::default().fg(CREAM))); + f.render_widget(Paragraph::new(Line::from(head)), rows[0]); + // slice the cached full series to the chosen window + let full = if app.tw == 0 { &app.t_hourly } else { &app.t_daily }; + let take = [24usize, 7, 30][app.tw]; + let start = full.len().saturating_sub(take); + let slice = &full[start..]; + let bw: u16 = if app.tw == 0 { 3 } else if app.tw == 1 { 9 } else { 2 }; + let bars: Vec = slice.iter().map(|(l, v)| + Bar::default().value(*v).label(Line::from(l.as_str())).text_value(if *v == 0 { "·".into() } else { human(*v as f64) }) + .style(Style::default().fg(Color::Cyan)).value_style(Style::default().fg(INK).bg(Color::Cyan)) + ).collect(); + let title = if slice.iter().all(|(_, v)| *v == 0) { + format!(" bandwidth / {} — no data yet (vnstat still collecting) ", win) + } else { format!(" bandwidth / {} (rx+tx per {}) ", win, if app.tw == 0 { "hour" } else { "day" }) }; + let bc = BarChart::default().block(Block::default().borders(Borders::ALL).title(title).border_style(Style::default().fg(CREAM))) + .data(BarGroup::default().bars(&bars)).bar_width(bw).bar_gap(1); + f.render_widget(bc, rows[1]); +} + +fn sec_tab(f: &mut Frame, area: Rect, app: &mut App) { + let rows = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]).split(area); + // sub-tab chips: highlighted one in ink-on-cream, rest dim — with a clear "[ ]" hint + let mut subspans: Vec = vec![Span::styled("[ ] ", Style::default().fg(CREAM).add_modifier(Modifier::BOLD))]; + for (i, s) in SUBS.iter().enumerate() { + let st = if i == app.sub { Style::default().fg(INK).bg(CREAM).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Gray) }; + subspans.push(Span::styled(format!(" {} ", s), st)); + subspans.push(Span::raw(" ")); + } + f.render_widget(Paragraph::new(Line::from(subspans)), rows[0]); + f.render_widget(Paragraph::new(Line::from(vec![ + Span::styled(format!(" banned {} ", app.ban_count), Style::default().fg(Color::Red)), + Span::styled(format!(" blacklist {} ", app.bl_count), Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled(format!(" whitelist {} ", app.wl_count), Style::default().fg(Color::Green)), + Span::styled(format!(" tracked {} ", app.reg.len()), Style::default().fg(CREAM)), + ])), rows[1]); + let data = app.rows_security(); + let max = data.first().map(|(_, e)| e.attempts.max(1)).unwrap_or(1); + let trows: Vec = data.iter().map(|(ip, e)| { + let stat = status_of(e); + let col = if e.whitelisted { Color::Green } else if e.blacklisted || e.banned { Color::Red } else if e.logins > 0 { Color::Cyan } else { Color::White }; + let bw = (24.0 * e.attempts as f64 / max as f64) as usize; + Row::new(vec![ + Cell::from(ip.clone()), Cell::from(e.country.clone()), + Cell::from(format!("{}", e.attempts)), Cell::from(format!("{}", e.logins)), + Cell::from(Span::styled(stat, Style::default().fg(col).add_modifier(Modifier::BOLD))), + Cell::from(Span::styled("█".repeat(bw), Style::default().fg(Color::Red))), + ]) + }).collect(); + let mut ts = TableState::default(); ts.select(Some(app.sel[4])); + let t = Table::new(trows, vec![Constraint::Length(16), Constraint::Length(14), Constraint::Length(8), Constraint::Length(6), Constraint::Length(10), Constraint::Min(10)]) + .header(Row::new(vec!["IP", "COUNTRY", "ATT", "LOGIN", "STATUS", "ATTEMPTS"]).style(Style::default().fg(CREAM).add_modifier(Modifier::BOLD))) + .block(Block::default().borders(Borders::ALL).title(format!(" {} ({}) ", SUBS[app.sub], data.len())).border_style(Style::default().fg(CREAM))) + .highlight_style(Style::default().fg(INK).bg(CREAM)).highlight_symbol("» "); + f.render_stateful_widget(t, rows[2], &mut ts); +} + +fn ssh_tab(f: &mut Frame, area: Rect, app: &mut App) { + let rows = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(3), Constraint::Min(0)]).split(area); + let warn = app.ssh.pass == "yes"; + let hdr = format!("PermitRootLogin {} PasswordAuth {} Pubkey {} Port {}\nattacker IPs in logs: {}", + app.ssh.root, app.ssh.pass, app.ssh.pubkey, app.ssh.port, app.ssh.attackers); + f.render_widget(Paragraph::new(hdr).style(Style::default().fg(if warn { Color::Red } else { Color::Green })) + .block(Block::default().borders(Borders::ALL).title(" SSH policy ").border_style(Style::default().fg(CREAM))), rows[0]); + let trows: Vec = app.sess.iter().map(|s| Row::new(vec![ + Cell::from(format!("{}{}", s.user, if s.me { " (you)" } else { "" })), Cell::from(s.tty.clone()), + Cell::from(s.from.clone()), Cell::from(s.country.clone()), Cell::from(s.since.clone())])).collect(); + let mut ts = TableState::default(); ts.select(Some(app.sel[5])); + let t = Table::new(trows, vec![Constraint::Length(16), Constraint::Length(10), Constraint::Length(18), Constraint::Length(16), Constraint::Min(10)]) + .header(Row::new(vec!["USER", "TTY", "FROM", "COUNTRY", "SINCE"]).style(Style::default().fg(CREAM).add_modifier(Modifier::BOLD))) + .block(Block::default().borders(Borders::ALL).title(format!(" live sessions ({}) — k kick · K kick+blacklist ", app.sess.len())).border_style(Style::default().fg(CREAM))) + .highlight_style(Style::default().fg(INK).bg(CREAM)).highlight_symbol("» "); + f.render_stateful_widget(t, rows[1], &mut ts); +} + +fn users_tab(f: &mut Frame, area: Rect, app: &mut App) { + let trows: Vec = app.users.iter().map(|u| { + let col = if u.locked { Color::Red } else { Color::White }; + Row::new(vec![ + Cell::from(Span::styled(u.user.clone(), Style::default().fg(col))), Cell::from(u.uid.clone()), + Cell::from(if u.sudo { "yes" } else { "-" }), Cell::from(format!("{}", u.live)), + Cell::from(if u.locked { "LOCKED" } else { "-" }), Cell::from(if u.key { "yes" } else { "no" }), Cell::from(u.last.clone())]) + }).collect(); + let mut ts = TableState::default(); ts.select(Some(app.sel[6])); + let t = Table::new(trows, vec![Constraint::Length(12), Constraint::Length(6), Constraint::Length(5), Constraint::Length(5), Constraint::Length(7), Constraint::Length(4), Constraint::Min(10)]) + .header(Row::new(vec!["USER", "UID", "SUDO", "LIVE", "LOCK", "KEY", "LASTLOGIN"]).style(Style::default().fg(CREAM).add_modifier(Modifier::BOLD))) + .block(Block::default().borders(Borders::ALL).title(format!(" Users ({}) — N new · P passwd · G/g sudo · L/U lock · D delete ", app.users.len())).border_style(Style::default().fg(CREAM))) + .highlight_style(Style::default().fg(INK).bg(CREAM)).highlight_symbol("» "); + f.render_stateful_widget(t, area, &mut ts); +} + +// Applications = the user-facing apps Traefik routes (subdomain + port + status + enable/disable). +fn apps_tab(f: &mut Frame, area: Rect, app: &mut App) { + let up = app.apps.iter().filter(|a| a.status.starts_with("Up")).count(); + let rows: Vec = app.apps.iter().map(|a| { + let healthy = a.status.starts_with("Up") && !a.status.contains("unhealthy"); + let dot = if healthy { Span::styled("●", Style::default().fg(Color::Green)) } + else if a.status.contains("unhealthy") { Span::styled("●", Style::default().fg(Color::Yellow)) } + else { Span::styled("○", Style::default().fg(Color::Red)) }; + let enabled = a.status.starts_with("Up"); + let state = Span::styled(if enabled { "enabled" } else { "disabled" }, + Style::default().fg(if enabled { Color::Green } else { Color::Red }).add_modifier(Modifier::BOLD)); + Row::new(vec![ + Cell::from(dot), Cell::from(a.name.clone()), Cell::from(state), + Cell::from(Span::styled(a.domain.clone(), Style::default().fg(Color::Cyan))), + Cell::from(if a.port.is_empty() { "-".into() } else { a.port.clone() }), + Cell::from(if a.hostports.is_empty() { "-".into() } else { a.hostports.clone() }), + ]) + }).collect(); + let mut ts = TableState::default(); ts.select(Some(app.sel[8])); + let t = Table::new(rows, vec![Constraint::Length(1), Constraint::Length(20), Constraint::Length(9), Constraint::Length(30), Constraint::Length(7), Constraint::Min(10)]) + .header(Row::new(vec!["", "APP", "STATE", "SUBDOMAIN", "PORT", "HOST PORTS"]).style(Style::default().fg(CREAM).add_modifier(Modifier::BOLD))) + .block(Block::default().borders(Borders::ALL).title(format!(" Applications ({}/{} enabled) — s enable · x disable · R restart · l logs · ↵ details ", up, app.apps.len())).border_style(Style::default().fg(CREAM))) + .highlight_style(Style::default().fg(INK).bg(CREAM)).highlight_symbol("» "); + f.render_stateful_widget(t, area, &mut ts); +} + +fn settings_tab(f: &mut Frame, area: Rect, app: &mut App) { + let rows = Layout::default().direction(Direction::Vertical) + .constraints([Constraint::Length(5), Constraint::Min(0)]).split(area); + let granted = app.users.iter().filter(|u| u.access && u.uid != "0").count(); + let info = Paragraph::new(vec![ + Line::from(Span::styled(" avni — access control", Style::default().fg(CREAM).add_modifier(Modifier::BOLD))), + Line::from(Span::styled(" This tool is restricted to root + members of the 'avni' group. Granted users run `avni` and it auto-elevates via sudo.", Style::default().fg(Color::Gray))), + Line::from(Span::styled(" RULE: a user must be an admin (sudo) FIRST — make them sudo in the Users tab (G), then grant here.", Style::default().fg(Color::Yellow))), + Line::from(Span::styled(format!(" currently granted (besides root): {}", granted), Style::default().fg(Color::Green))), + ]).block(Block::default().borders(Borders::ALL).title(" Settings · who can use avni ").border_style(Style::default().fg(CREAM))); + f.render_widget(info, rows[0]); + let trows: Vec = app.users.iter().map(|u| { + let is_root = u.uid == "0"; + let access = if u.access { Span::styled("GRANTED", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) } else { Span::styled("-", Style::default().fg(Color::DarkGray)) }; + // eligibility: needs sudo before it can be granted + let elig = if is_root { Span::styled("root", Style::default().fg(CREAM)) } + else if u.sudo { Span::styled("admin — can grant", Style::default().fg(Color::Cyan)) } + else { Span::styled("not admin — sudo first", Style::default().fg(Color::Yellow)) }; + Row::new(vec![Cell::from(u.user.clone()), Cell::from(u.uid.clone()), + Cell::from(if u.sudo { "yes" } else { "-" }), Cell::from(access), Cell::from(elig)]) + }).collect(); + let mut ts = TableState::default(); ts.select(Some(app.sel[7])); + let t = Table::new(trows, vec![Constraint::Length(14), Constraint::Length(6), Constraint::Length(6), Constraint::Length(10), Constraint::Min(10)]) + .header(Row::new(vec!["USER", "UID", "SUDO", "AVNI", "ELIGIBILITY"]).style(Style::default().fg(CREAM).add_modifier(Modifier::BOLD))) + .block(Block::default().borders(Borders::ALL).title(" g grant avni · x revoke · ↵ activity ").border_style(Style::default().fg(CREAM))) + .highlight_style(Style::default().fg(INK).bg(CREAM)).highlight_symbol("» "); + f.render_stateful_widget(t, rows[1], &mut ts); +} + +fn centered(pw: u16, ph: u16, area: Rect) -> Rect { + let w = area.width * pw / 100; let h = area.height * ph / 100; + Rect { x: area.x + (area.width - w) / 2, y: area.y + (area.height - h) / 2, width: w, height: h } +} +fn centered_abs(w: u16, h: u16, area: Rect) -> Rect { + let w = w.min(area.width); let h = h.min(area.height); + Rect { x: area.x + (area.width - w) / 2, y: area.y + (area.height - h) / 2, width: w, height: h } +} + +// Loading splash shown until the first background snapshot arrives. +fn splash(f: &mut Frame, area: Rect, app: &App) { + f.render_widget(Block::default().style(Style::default().bg(INK)), area); + let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let ms = app.boot.elapsed().as_millis(); + let sp = frames[(ms / 90) as usize % frames.len()]; + let dots = ".".repeat((ms / 400) as usize % 4); + let lines = vec![ + Line::from(Span::styled("█ AVNI CLOUD", Style::default().fg(CREAM).add_modifier(Modifier::BOLD))).alignment(Alignment::Center), + Line::from(Span::styled("control center", Style::default().fg(CREAM))).alignment(Alignment::Center), + Line::from(""), + Line::from(Span::styled(format!("{} loading{}", sp, dots), Style::default().fg(CREAM).add_modifier(Modifier::BOLD))).alignment(Alignment::Center), + Line::from(Span::styled("services · domains · IP registry · sessions · traffic", Style::default().fg(Color::Gray))).alignment(Alignment::Center), + Line::from(Span::styled("first scan reads the auth logs — a moment on a busy box", Style::default().fg(Color::DarkGray))).alignment(Alignment::Center), + ]; + let h = lines.len() as u16; + let y = area.y + area.height.saturating_sub(h) / 2; + f.render_widget(Paragraph::new(lines), Rect { x: area.x, y, width: area.width, height: h.min(area.height) }); +} + +// Headless render of the TUI to an in-memory buffer (for verification without a tty). +// Usage: avni __render [modal=blacklist|whitelist|popup] [sub 0-6] +fn render_debug(args: &[String]) { + use ratatui::backend::TestBackend; + let tab: usize = args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0); + let modal = args.get(1).cloned().unwrap_or_default(); + let sub: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); + let mut app = App::blank(); + // populate with a real snapshot so the render reflects live data + let s = gather(); + app.ov = s.ov; app.svcs = s.svcs; app.doms = s.doms; app.apps = s.apps; app.reg = s.reg; + app.sess = s.sess; app.users = s.users; app.ssh = s.ssh; + app.t_hourly = s.t_hourly; app.t_daily = s.t_daily; app.t_today = s.t_today; app.iface = s.iface; + app.bl_count = s.bl_count; app.ban_count = s.ban_count; app.wl_count = s.wl_count; + app.ready = true; app.tab = tab; app.sub = sub; app.status = "render-test".into(); + match modal.as_str() { + "blacklist" => { app.input = Input::Blacklist; app.buf = "198.51.100.23".into(); } + "whitelist" => { app.input = Input::Whitelist; app.buf = "203.0.113.9".into(); } + "newuser" => { app.input = Input::NewUser; app.buf = "alice".into(); } + "setpasswd" => { app.input = Input::SetPasswd; app.pw_user = "alice".into(); app.buf = "S3cret-pw".into(); } + "confirm" => { app.confirm = Some("bob".into()); } + "activity" => { let u = app.users.get(0).map(|u| u.user.clone()).unwrap_or_else(|| "root".into()); app.popup = Some((format!("activity · {}", u), user_activity(&u))); } + "popup" => { app.popup = Some(("IP intel 1.2.3.4".into(), "country India\nattempts 42".into())); } + "splash" => { app.ready = false; } // show the loading screen + _ => {} + } + let backend = TestBackend::new(120, 40); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| ui(f, &mut app)).unwrap(); + // dump buffer rows as plain text + let buf = term.backend().buffer().clone(); + for y in 0..buf.area.height { + let mut line = String::new(); + for x in 0..buf.area.width { line.push_str(buf.get(x, y).symbol()); } + println!("{}", line.trim_end()); + } +} + +// ----------------------------------------------------------------- CLI (verify/scriptable) +fn cli(args: &[String]) -> io::Result<()> { + match args[0].as_str() { + "__render" => { render_debug(&args[1..]); return Ok(()); } + "status" => { let o = overview(); let s = services(); let up = s.iter().filter(|x| x.status.starts_with("Up")).count(); + println!("AVNI {} ({})\n load {:.2} {:.2} {:.2} · ram {}/{}MB · disk {}%\n services {}/{} · domains {} · banned {} · blacklist {} · tracked {}", + o.host, o.ip, o.load[0], o.load[1], o.load[2], o.mem_used, o.mem_total, o.disk_pct, up, s.len(), domains().len(), f2b_count(), blacklist_list().len(), reg_update().len()); } + "services" => for s in services() { println!(" {} {:<34}{:<26}{}", if s.status.starts_with("Up") { "●" } else { "○" }, s.name, s.status, s.domain); } + "apps" => for a in app_catalog() { println!(" {} {:<18} {:<9} {:<28} :{:<6} {}", if a.status.starts_with("Up") { "●" } else { "○" }, + a.name, if a.status.starts_with("Up") { "enabled" } else { "disabled" }, a.domain, a.port, a.hostports); } + "ips" => { let last = SUBS.len() - 1; let which = args.get(1).map(|s| SUBS.iter().position(|x| x.eq_ignore_ascii_case(s)).unwrap_or(last)).unwrap_or(last); + let reg = reg_update(); let rows = if which == last { let mut v: Vec<_> = reg.iter().map(|(k, r)| (k.clone(), r.clone())).collect(); v.sort_by(|a, b| b.1.attempts.cmp(&a.1.attempts)); v } else { reg_filter(®, which) }; + println!(" {} : {} IPs (tracked {})", SUBS[which], rows.len(), reg.len()); + for (ip, e) in rows.iter().take(40) { println!(" {:<16}{:<14}{:>8}{:>6} {:<10}{}", ip, e.country, e.attempts, e.logins, status_of(e), e.last); } } + "attackers" => { let reg = reg_update(); let mut v: Vec<_> = reg.iter().filter(|(_, e)| e.attempts > 0).collect(); v.sort_by(|a, b| b.1.attempts.cmp(&a.1.attempts)); + for (ip, e) in v.iter().take(args.get(1).and_then(|n| n.parse().ok()).unwrap_or(15)) { println!(" {:>7} {:<16}{}", e.attempts, ip, e.country); } } + "ban" => { println!("{}", ban(&args[1])); } + "unban" => { println!("{}", unban(&args[1])); } + "blacklist" => { if args.len() > 2 && args[1] == "add" { println!("{}", blacklist_add(&args[2])); } else { for x in blacklist_list() { println!(" {} {}", x, geo(x.split('/').next().unwrap_or(&x))); } } } + "whitelist" => { if args.len() > 2 && args[1] == "add" { println!("{}", whitelist_add(&args[2])); } + else if args.len() > 2 && args[1] == "del" { println!("{}", whitelist_del(&args[2])); } + else { for x in whitelist_list() { println!(" {} {}", x, geo(x.split('/').next().unwrap_or(&x))); } } } + "geo" => println!("{}", geo(&args[1])), + "ssh" => { let c = ssh_cfg(); println!(" root={} pass={} pubkey={} port={}", c.root, c.pass, c.pubkey, c.port); for s in sessions() { println!(" {}@{:<8}{:<16}({})", s.user, s.tty, s.from, s.country); } } + "users" => for u in users_list() { println!(" {:<12}{:<6} sudo={} live={} lock={} key={} {}", u.user, u.uid, u.sudo, u.live, u.locked, u.key, u.last); } + "adduser" => { if args.len() > 1 { println!("{}", create_user(&args[1..].join(" "))); } else { println!("usage: avni adduser [password]"); } } + "sudo" => { if args.len() > 1 { println!("{}", grant_sudo(&args[1])); } else { println!("usage: avni sudo "); } } + "unsudo" => { if args.len() > 1 { println!("{}", revoke_sudo(&args[1])); } else { println!("usage: avni unsudo "); } } + "deluser" => { if args.len() > 1 { println!("{}", del_user(&args[1])); } else { println!("usage: avni deluser "); } } + "passwd" => { if args.len() > 2 { println!("{}", set_passwd(&args[1], &args[2])); } else { println!("usage: avni passwd (Linux login / SSH password)"); } } + "activity" => { if args.len() > 1 { println!("{}", user_activity(&args[1])); } else { println!("usage: avni activity "); } } + "access" => println!("root\n{}", sh("getent group avni").rsplit(':').next().unwrap_or("").split(',').filter(|u| !u.is_empty()).collect::>().join("\n")), + "grant" => { if args.len() > 1 { println!("{}", grant_access(&args[1])); } else { println!("usage: avni grant (user must already have sudo)"); } } + "revoke" => { if args.len() > 1 { println!("{}", revoke_access(&args[1])); } else { println!("usage: avni revoke "); } } + _ => println!("avni: status|services|apps|ips [filter]|attackers [N]|ban |unban |blacklist [add ]|whitelist [add|del ]|geo |ssh|users|adduser [pw]|passwd |sudo |unsudo |deluser |activity |access|grant |revoke (no args = TUI)"), + } + Ok(()) +}