first commit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 20:36:08 +02:00
commit 8612b38bee
10 changed files with 3109 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Rust build output
/target
**/*.rs.bk
# Python
__pycache__/
*.pyc
# editor / OS
*.swp
.DS_Store

568
Cargo.lock generated Normal file
View File

@@ -0,0 +1,568 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "avni"
version = "0.2.0"
dependencies = [
"crossterm",
"ratatui",
"serde",
"serde_json",
]
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "compact_str"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"ryu",
"static_assertions",
]
[[package]]
name = "crossterm"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
dependencies = [
"bitflags",
"crossterm_winapi",
"libc",
"mio",
"parking_lot",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "itertools"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
]
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ratatui"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm",
"itertools 0.12.1",
"lru",
"paste",
"stability",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "stability"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools 0.13.0",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

18
Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "avni"
version = "0.2.0"
edition = "2021"
[[bin]]
name = "avni"
path = "src/main.rs"
[dependencies]
ratatui = "0.26"
crossterm = "0.27"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = 2
codegen-units = 16

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jagadeesh (AVNI Cloud)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

201
README.md Normal file
View File

@@ -0,0 +1,201 @@
# avni — a single-binary control center for your VPS
`avni` is a fast, dependency-light **terminal dashboard (TUI) + CLI** for operating a
self-hosted Linux server: see your Docker apps, domains, bandwidth, SSH sessions and
brute-force attackers at a glance — and act on them (start/stop services, ban/blacklist
IPs, manage users) without leaving the terminal.
It shells out to the tools already on the box (`docker`, `fail2ban-client`, `ipset`,
`vnstat`, `who`/`last`, `getent`, …) — **no daemon, no database, no agent**. One Rust
binary, ~2% CPU, microsecond redraws.
> Built for a small multi-app VPS behind [Traefik](https://traefik.io/). It auto-discovers
> the apps Traefik routes, so the dashboard never goes stale as you add or remove services.
```
┌ AVNI CLOUD · control center ─────────────────────────────────────────────────────────┐
│ 1.Overview · 2.Services · 3.Domains · 4.Traffic · 5.Security · 6.SSH · 7.Users · 8.Set…│
└────────────────────────────────────────────────────────────────────────────────────────┘
┌ Applications (9/9 enabled) — s enable · x disable · R restart · l logs · ↵ details ─────┐
│ APP STATE SUBDOMAIN PORT HOST PORTS │
│ » ● gitea enabled git.example.com 3000 127.0.0.1:3000->3000 │
│ ● n8n enabled n8n.example.com 5678 127.0.0.1:5678->5678 │
│ ● uptime-kuma enabled uptime.example.com 3001 127.0.0.1:3001->3001 │
│ ○ analytics disabled stats.example.com 3000 - │
└──────────────────────────────────────────────────────────────────────────────────────────┘
s enable x disable R restart l logs ↵ details 1-9/⇄ tabs ↑↓ move r refresh
```
---
## Table of contents
- [Features](#features)
- [Requirements](#requirements)
- [Install](#install)
- [Using the TUI](#using-the-tui)
- [CLI](#cli-scriptable)
- [Access control — who can run `avni`](#access-control--who-can-run-avni)
- [How it works](#how-it-works)
- [Data & file locations](#data--file-locations)
- [Security notes](#security-notes)
- [Build from source](#build-from-source)
- [Python fallback](#python-fallback)
- [License](#license)
---
## Features
A tabbed dashboard (switch with `1``9`, `Tab`, or `←/→`):
| Tab | What it shows / does |
|-----|----------------------|
| **Overview** | host, IP, uptime, load/memory/disk gauges, service & attacker summary, dual clock (server + your local time) |
| **Services** | every Docker container with status + the domain Traefik routes to it; `s`/`x`/`R` start/stop/restart, `l` logs |
| **Domains** | every `Host()` route → backend; `Enter` checks live HTTP status + TLS cert expiry |
| **Traffic** | `vnstat` bandwidth bar chart, 24h / 7d / 30d windows (pins the real uplink interface) |
| **Security** | a persistent **IP registry** of everyone who has touched the box (country, attempts, logins, status). Temp-ban (fail2ban), permanent firewall **blacklist** (ipset+iptables), and a **whitelist** that can never be banned |
| **SSH** | live sessions (kick / kick+blacklist) + the effective `sshd` policy |
| **Users** | list/create/delete Linux users, grant/revoke sudo, lock/unlock, **set login password**, and a full per-user **activity** view (system metadata only) |
| **Settings** | delegate the tool to other admins (see [access control](#access-control--who-can-run-avni)) |
| **Apps** | the user-facing apps Traefik routes — **enable/disable**, ports, subdomain, live HTTPS + cert details |
Highlights:
- **Auto-discovery** — apps and domains come straight from container labels; nothing to configure.
- **Persistent attacker registry** with **incremental log parsing** (reads only newly-appended bytes of `auth.log`, survives rotation).
- **Optimistic UI** — actions reflect instantly, then reconcile with the real system state.
- **Dual time** — every timestamp shows a second timezone beside it (configured for IST in the source; see [Build](#build-from-source) to change).
- **Scriptable** — every view is also a one-shot CLI subcommand.
## Requirements
- Linux, run as **root** (or as a delegated `avni`-group member — it self-elevates via sudo).
- A recent **Rust** toolchain (rustup stable; ratatui 0.26 needs a newer rustc than some distros ship).
- Expected on the box (features degrade gracefully if missing):
`docker`, `fail2ban-client`, `ipset` + `iptables`, `vnstat`, `geoiplookup` (geoip-bin),
`whois`, plus coreutils (`who`, `last`, `lastb`, `getent`, `passwd`, `chpasswd`).
## Install
```bash
git clone https://git.example.com/you/vps-tool-avni.git
cd vps-tool-avni
sudo bash install.sh
```
`install.sh` builds the release binary and installs it to `/usr/local/bin/avni`, installs the
firewall helper to `/usr/local/bin/avni-firewall.sh`, creates the `avni` group, and writes a
sudoers drop-in so delegated admins can launch it. Re-run it after pulling changes.
Then just:
```bash
avni # full-screen dashboard
avni status # one-shot CLI
```
## Using the TUI
Global keys: `1``9` / `Tab` / `←→` switch tabs · `↑↓` (or `j`/`k`) move · `r` refresh · `q` quit.
Per-tab actions are always shown in the footer. The most useful:
- **Services / Apps** — `s` start/enable · `x` stop/disable · `R` restart · `l` logs · `Enter` details
- **Security** — `[` `]` switch sub-tab · `a` add blacklist · `A` add whitelist · `b` ban · `B` blacklist selected · `W` whitelist selected · `u` undo · `w` whois · `Enter` IP intel
- **SSH** — `k` kick a session · `K` kick + blacklist its IP
- **Users** — `N` new user · `P` set login/SSH password (masked) · `G`/`g` grant/revoke sudo · `L`/`U` lock/unlock · `D` delete (keeps `/home`) · `Enter` activity
- **Settings** — `g` grant the tool · `x` revoke
Full reference: **[docs/guide.md](docs/guide.md)**.
## CLI (scriptable)
Every subcommand prints the same data the TUI shows:
```bash
avni status # host + counts
avni services # all containers
avni apps # user-facing apps: state · subdomain · port
avni ips [attackers|banned|new|all] # the IP registry
avni attackers [N] # top N attackers with country
avni ban <ip> | unban <ip>
avni blacklist [add <ip|cidr|domain>]
avni whitelist [add|del <ip>]
avni geo <ip> | ssh | users
avni adduser <user> [password] # create account (-m home, bash)
avni passwd <user> <password> # set a user's Linux login / SSH password
avni sudo <user> | unsudo <user> # grant / revoke sudo
avni deluser <user> # delete account, KEEP /home
avni activity <user> # logins, failed logins, sudo cmds, processes
avni access | grant <user> | revoke <user> # tool delegation
```
## Access control — who can run `avni`
The tool is **root-only** by default. To let a teammate use it:
1. They must already be an **admin (sudo)**`avni sudo <user>` (Users tab `G`).
2. Grant the tool — `avni grant <user>` (Settings tab `g`). This adds them to the `avni`
group; the sudoers drop-in lets the binary **auto-elevate via sudo** when they run it.
Anyone not granted gets a polite "not authorised" message. Root can never be revoked.
The single binary at `/usr/local/bin/avni` is a real root-owned file (mode 0755), so a
delegated user can exec it but cannot modify it.
## How it works
- **Background gather** — a worker thread fans out the independent fetches (docker, auth-log
registry, sessions, users, vnstat) across threads, so a full refresh ≈ the slowest single
part (~2 s) instead of the sum. The UI never blocks; a loading splash shows on first start.
- **Incremental log parsing** — stores a byte offset per log inode and reads only the new tail
of `auth.log` on each refresh (not the whole multi-MB file).
- **Auto-discovery** — services, domains and apps are parsed from `docker ps` + Traefik
`Host()` labels in a single call. Nothing is hard-coded; add a container and it appears.
- **Traffic** pins the **default-route interface** (a busy box can have dozens of docker
veth/bridge interfaces; `vnstat`'s `interfaces[0]` is often a zero-traffic bridge).
## Data & file locations
| Path | Purpose |
|------|---------|
| `/usr/local/bin/avni` | the binary |
| `/usr/local/bin/avni-firewall.sh` | blacklist/whitelist engine (ipset + iptables) |
| `/var/lib/avni/ips.json` | persistent IP registry (+ per-log byte offsets) |
| `/etc/avni/whitelist.txt`, `/etc/avni/blacklist.save` | firewall state |
| `/etc/fail2ban/jail.d/avni-whitelist.conf` | auto-generated fail2ban `ignoreip` |
| `/etc/sudoers.d/avni` | NOPASSWD drop-in for the `avni` group |
## Security notes
- **No secrets are stored in this repo or the binary.** `avni` reads live system state at
runtime; it never embeds passwords, IPs or hostnames. (The examples in the code are
[RFC 5737](https://datatracker.ietf.org/doc/html/rfc5737) documentation addresses.)
- **Whitelist your own IPs first.** A firewall guard and fail2ban `ignoreip` then make it
impossible to accidentally lock yourself out under a brute-force burst; an anti-lockout
check also refuses to blacklist your current SSH client IP.
- **Docker-published ports bypass host UFW** — the firewall engine adds rules to the
`DOCKER-USER` chain too, so a blacklist actually blocks container-exposed ports.
- The per-user **activity** view uses only system metadata (logins, sudo audit, `ps`) and
**never reads anyone's home directory or shell history** — safe on a multi-tenant box.
## Build from source
```bash
cargo build --release # at the repo root → target/release/avni
```
Toolchain: rustup **stable**. Dependencies are just `ratatui`, `crossterm`, `serde`,
`serde_json` (see `Cargo.toml`).
**Changing the second timezone:** the dual-clock offset is computed in `ist_delta_min()` in
`src/main.rs` (currently targets IST, +05:30). Adjust the `330` (minutes) and the
`TZ=Asia/Kolkata` strings to your zone.
## Python fallback
`avni.py` is an earlier pure-Python (curses) implementation kept as a zero-build fallback.
`install.sh` installs it as `/usr/local/bin/avni-py`. The Rust binary is the maintained one.
## License
[MIT](LICENSE) © 2026 Jagadeesh (AVNI Cloud).

75
avni-firewall.sh Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# AVNI firewall: persistent blacklist (ipset+iptables, host + Docker) and a
# WHITELIST that can never be banned/blacklisted (fail2ban ignoreip + guard).
set -uo pipefail
SET=avni_blacklist
SAVE=/etc/avni/blacklist.save
WL=/etc/avni/whitelist.txt
F2B_CONF=/etc/fail2ban/jail.d/avni-whitelist.conf
mkdir -p /etc/avni; touch "$WL"
ensure_set(){ ipset list -n 2>/dev/null | grep -qx "$SET" || \
ipset create "$SET" hash:net family inet hashsize 1024 maxelem 65536; }
ensure_rules(){
iptables -C INPUT -m set --match-set "$SET" src -j DROP 2>/dev/null || \
iptables -I INPUT 1 -m set --match-set "$SET" src -j DROP
if iptables -L DOCKER-USER -n >/dev/null 2>&1; then
iptables -C DOCKER-USER -m set --match-set "$SET" src -j DROP 2>/dev/null || \
iptables -I DOCKER-USER 1 -m set --match-set "$SET" src -j DROP
fi
}
save(){ ipset save "$SET" > "$SAVE" 2>/dev/null; }
my_ip(){ echo "${SSH_CLIENT%% *}"; }
is_wl(){ grep -qxF "$1" "$WL" 2>/dev/null; }
jails(){ fail2ban-client status 2>/dev/null | sed -n 's/.*Jail list:[[:space:]]*//p' | tr ',' ' '; }
gen_f2b(){ # write a persistent ignoreip drop-in from the whitelist + reload
local ips; ips=$(tr '\n' ' ' < "$WL")
{ echo "# AUTO-GENERATED by avni-firewall.sh — do not edit; use: avni whitelist add/del"
echo "[DEFAULT]"
echo "ignoreip = 127.0.0.1/8 ::1 $ips"; } > "$F2B_CONF"
fail2ban-client reload >/dev/null 2>&1 || true
}
resolve(){
if [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?$ ]]; then echo "$1"; return; fi
getent ahostsv4 "$1" 2>/dev/null | awk '{print $1}' | sort -u
}
case "${1:-}" in
init) ensure_set; ensure_rules; save ;;
restore) if [ -f "$SAVE" ]; then ipset restore -! < "$SAVE" 2>/dev/null; else ensure_set; fi; ensure_rules; gen_f2b ;;
add)
ensure_set; ensure_rules
for ip in $(resolve "$2"); do
if [ "$ip" = "$(my_ip)" ]; then echo "REFUSED: $ip is YOUR current SSH IP"; continue; fi
if is_wl "$ip"; then echo "REFUSED: $ip is WHITELISTED (remove from whitelist first)"; continue; fi
ipset add -! "$SET" "$ip" && echo "blacklisted $ip"
done; save ;;
del)
for ip in $(resolve "$2"); do ipset del "$SET" "$ip" 2>/dev/null && echo "removed $ip" || echo "$ip not listed"; done; save ;;
list) ipset list "$SET" 2>/dev/null | sed -n '/Members:/,$p' | tail -n +2 ;;
count) ipset list "$SET" 2>/dev/null | sed -n '/Members:/,$p' | tail -n +2 | grep -c . ;;
whitelist)
case "${2:-list}" in
add)
for ip in $(resolve "$3"); do
grep -qxF "$ip" "$WL" || echo "$ip" >> "$WL"
ipset del "$SET" "$ip" 2>/dev/null # un-blacklist if present
for j in $(jails); do
fail2ban-client set "$j" addignoreip "$ip" >/dev/null 2>&1
fail2ban-client set "$j" unbanip "$ip" >/dev/null 2>&1
done
echo "whitelisted + unbanned $ip (can never be banned/blacklisted)"
done
sort -u "$WL" -o "$WL"; gen_f2b; save ;;
del)
for ip in $(resolve "$3"); do
grep -vxF "$ip" "$WL" > "$WL.tmp" && mv "$WL.tmp" "$WL"
for j in $(jails); do fail2ban-client set "$j" delignoreip "$ip" >/dev/null 2>&1; done
echo "removed $ip from whitelist"
done; gen_f2b ;;
list|*) [ -s "$WL" ] && cat "$WL" || echo "(whitelist empty)" ;;
esac ;;
*) echo "usage: $0 {init|restore|add <ip|cidr|domain>|del <ip>|list|count|whitelist {add|del|list} <ip>}"; exit 1 ;;
esac

715
avni.py Executable file
View File

@@ -0,0 +1,715 @@
#!/usr/bin/env python3
"""
AVNI Cloud — control center.
Run bare: avni -> full-screen TUI
Or CLI: avni <command>
status host overview (gauges in TUI)
services containers + status + domain
domains domains + live HTTPS + cert expiry
traffic [24h|7d|30d] bandwidth charts (vnstat) + per-container net
perdomain per-domain requests + bytes (needs access log)
bans fail2ban jails + banned IPs
attackers [N] top N SSH brute-force IPs (+country)
ipinfo <ip> full intel on one IP (country/attempts/timeline/users)
geo <ip> country of an IP
ban|unban <ip> [jail] temporary fail2ban ban/unban
blacklist [add|del <x>] PERMANENT firewall blacklist (ip|cidr|domain)
whitelist fail2ban ignoreip
ssh sshd policy + sessions(+country) + recent logins
kick <ip|pts/N> kill SSH session(s) by IP or tty
users system users: sudo/live/locked/key/lastlogin
accesslog status|on Traefik per-domain access logging
Theme: AVNI ink #0e0a09 / cream #f2ddc0
"""
import curses, subprocess, shutil, re, json, os, sys, time, glob
from datetime import datetime
IFACE_DEFAULT = None
TRAEFIK = "appwrite-traefik"
ACCESS_LOG = "/var/log/traefik/access.log"
FW = "/usr/local/bin/avni-firewall.sh"
AUTH_LOGS = ["/var/log/auth.log", "/var/log/auth.log.1"]
_GEO = {}
BLOCKS = "▁▂▃▄▅▆▇█"
# --------------------------------------------------------------------------- shell
def sh(cmd, timeout=8):
try: return subprocess.run(cmd.split(), capture_output=True, text=True, timeout=timeout).stdout.strip()
except Exception: return ""
def shl(args, timeout=8):
try: return subprocess.run(args, capture_output=True, text=True, timeout=timeout).stdout.strip()
except Exception: return ""
def sh_raw(cmd, timeout=8):
try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout).stdout
except Exception: return ""
def has(t): return shutil.which(t) is not None
def iface():
global IFACE_DEFAULT
if IFACE_DEFAULT: return IFACE_DEFAULT
m = re.search(r"default.* dev (\S+)", sh("ip route")); IFACE_DEFAULT = m.group(1) if m else "eth0"
return IFACE_DEFAULT
def human(n):
n=float(n)
for u in ("B","K","M","G","T"):
if n<1024: return f"{n:.0f}{u}" if u=="B" else f"{n:.1f}{u}"
n/=1024
return f"{n:.1f}P"
# --------------------------------------------------------------------------- charts (ascii)
def bar(val, mx, width, fill="", empty=""):
if mx<=0: return empty*width
f=int(round(width*min(val,mx)/mx)); return fill*f+empty*(width-f)
def spark(vals):
if not vals: return ""
mx=max(vals) or 1
return "".join(BLOCKS[min(len(BLOCKS)-1,int((v/mx)*(len(BLOCKS)-1)))] for v in vals)
# --------------------------------------------------------------------------- geo / intel
def geo(ip):
if ip in _GEO: return _GEO[ip]
c="?"
if has("geoiplookup"):
out=sh(f"geoiplookup {ip}")
m=re.search(r"GeoIP Country Edition:\s*[A-Z]{2},\s*(.+)", out)
if m: c=m.group(1).strip()
elif "not found" in out.lower() or "can't" in out.lower(): c=""
_GEO[ip]=c; return c
def ts_of(line):
m=re.match(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})", line)
if m: return m.group(1).replace("T"," ")
m=re.match(r"([A-Z][a-z]{2}\s+\d+\s\d{2}:\d{2}:\d{2})", line)
return m.group(1) if m else "?"
def ip_intel(ip):
cnt=0; first=last=None; users={}
ipre=re.compile(re.escape(ip))
ure=re.compile(r"(?:Invalid user|user)\s+([A-Za-z0-9._-]+)")
for f in AUTH_LOGS:
if not os.path.exists(f): continue
try:
with open(f, errors="ignore") as fh:
for line in fh:
if ipre.search(line) and re.search(r"Failed|Invalid|authenticating|closed by|Disconnect", line):
cnt+=1; t=ts_of(line)
if first is None or t<first: first=t
if last is None or t>last: last=t
mu=ure.search(line)
if mu: users[mu.group(1)]=users.get(mu.group(1),0)+1
except Exception: pass
banned = any(ip in f2b_status(j)["ips"] for j in f2b_jails())
bl = subprocess.run(["ipset","test","avni_blacklist",ip],capture_output=True).returncode==0
org=""
if has("whois"):
w=sh(f"whois {ip}",10)
mo=re.search(r"(?:OrgName|org-name|netname|descr):\s*(.+)", w, re.I)
if mo: org=mo.group(1).strip()
topu=sorted(users.items(),key=lambda x:-x[1])[:6]
return {"ip":ip,"country":geo(ip),"attempts":cnt,"first":first or "","last":last or "",
"users":topu,"banned":banned,"blacklisted":bl,"org":org}
# --------------------------------------------------------------------------- docker / services
def overview():
mem=next((l for l in sh_raw("free -m").splitlines() if l.lower().startswith("mem")),"").split()
mtot,mused,mavail=(int(mem[1]),int(mem[2]),int(mem[-1])) if len(mem)>=7 else (0,0,0)
d=sh("df -h /").splitlines(); dl=d[1].split() if len(d)>1 else [""]*6
du=sh("df / ").splitlines(); dpct=du[1].split()[4] if len(du)>1 else "0%"
load=open("/proc/loadavg").read().split()[:3]
return {"host":sh("hostname"),"ip":(sh("curl -s -m4 https://api.ipify.org") or ""),
"os":sh_raw(". /etc/os-release; echo $PRETTY_NAME").strip(),"kernel":sh("uname -r"),
"uptime":sh("uptime -p"),"load":load,"ncpu":os.cpu_count() or 1,
"mem_total":mtot,"mem_used":mused,"mem_avail":mavail,
"disk_size":dl[1],"disk_used":dl[2],"disk_avail":dl[3],"disk_pct":dl[4]}
def traefik_domains():
out={}; ids=sh("docker ps -q").split()
if not ids: return out
try: data=json.loads(shl(["docker","inspect"]+ids,15) or "[]")
except Exception: return out
for c in data:
name=c.get("Name","").lstrip("/"); labels=(c.get("Config",{}) or {}).get("Labels",{}) or {}
for k,v in labels.items():
if k.endswith(".rule") and "Host(" in v:
for d in re.findall(r"Host\(`([^`]+)`\)",v): out.setdefault(d,name)
return out
def services():
rows=[]
out=shl(["docker","ps","-a","--format","{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"],12)
for line in out.splitlines():
p=line.split("\t")
if len(p)<3: continue
rows.append({"name":p[0],"image":p[1],"status":p[2],"ports":p[3] if len(p)>3 else ""})
c2d={}
for d,c in traefik_domains().items(): c2d.setdefault(c,[]).append(d)
for r in rows: r["domain"]=",".join(c2d.get(r["name"],[]))
return rows
def docker_net():
rows=[]
for line in shl(["docker","stats","--no-stream","--format","{{.Name}}\t{{.NetIO}}\t{{.CPUPerc}}"],20).splitlines():
p=line.split("\t")
if len(p)>=2: rows.append({"name":p[0],"net":p[1],"cpu":p[2] if len(p)>2 else ""})
return rows
def cert_expiry(domain):
out=sh_raw(f"echo | timeout 6 openssl s_client -servername {domain} -connect {domain}:443 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null")
m=re.search(r"notAfter=(.+)",out); return m.group(1).strip() if m else "?"
# --------------------------------------------------------------------------- fail2ban / blacklist
def f2b_jails():
m=re.search(r"Jail list:\s*(.*)",sh("fail2ban-client status"))
return [j.strip() for j in m.group(1).split(",")] if m and m.group(1).strip() else []
def f2b_status(jail):
out=sh(f"fail2ban-client status {jail}")
g=lambda p,d="0":(re.search(p,out).group(1) if re.search(p,out) else d)
ips=re.search(r"Banned IP list:\s*(.*)",out)
return {"failed":g(r"Currently failed:\s*(\d+)"),"banned":g(r"Currently banned:\s*(\d+)"),
"total":g(r"Total banned:\s*(\d+)"),"ips":ips.group(1).split() if ips and ips.group(1).strip() else []}
def f2b_banned_all():
return [{"jail":j,"ip":ip} for j in f2b_jails() for ip in f2b_status(j)["ips"]]
def f2b_ignoreip(jail="sshd"): return sh(f"fail2ban-client get {jail} ignoreip")
def ban(ip,jail="sshd"): return sh(f"fail2ban-client set {jail} banip {ip}")
def unban(ip,jail="sshd"): return sh(f"fail2ban-client set {jail} unbanip {ip}")
def blacklist_list(): return [x for x in sh(f"bash {FW} list").splitlines() if x.strip()]
def blacklist_add(x): return sh(f"bash {FW} add {x}",15)
def blacklist_del(x): return sh(f"bash {FW} del {x}",15)
def blacklist_count():
try: return int(sh(f"bash {FW} count") or "0")
except Exception: return 0
def attackers(n=15):
counts={}
pat=re.compile(r"(Failed password|Invalid user|authenticating user|Connection closed by|Disconnected from invalid)")
ipre=re.compile(r"(\d{1,3}\.){3}\d{1,3}")
for f in AUTH_LOGS:
if not os.path.exists(f): continue
try:
with open(f,errors="ignore") as fh:
for line in fh:
if pat.search(line):
m=ipre.search(line)
if m: counts[m.group(0)]=counts.get(m.group(0),0)+1
except Exception: pass
return [{"ip":ip,"count":c} for ip,c in sorted(counts.items(),key=lambda x:-x[1])[:n]]
def whois_ip(ip):
if not has("whois"): return "install: apt-get install -y whois"
keep=[l for l in sh(f"whois {ip}",10).splitlines() if re.match(r"^\s*(country|orgname|org-name|netname|descr|inetnum):",l,re.I)]
return "\n".join(keep[:8]) or "(no whois data)"
# --------------------------------------------------------------------------- IP registry (CrowdSec-style decisions store)
REG_PATH="/var/lib/avni/ips.json"
def registry_load():
try: return json.load(open(REG_PATH))
except Exception: return {}
def registry_save(d):
os.makedirs("/var/lib/avni",exist_ok=True)
try: json.dump(d,open(REG_PATH,"w"))
except Exception: pass
def registry_ips(reg): return [(i,e) for i,e in reg.items() if not i.startswith("_")]
def _scan_authlogs(offsets):
"""Read only NEW lines since last byte-offset (keyed by inode) -> fast."""
att={}; fs={}; ls={}; users={}; new=dict(offsets)
pat=re.compile(r"(Failed password|Invalid user|authenticating|closed by|Disconnect)")
ipre=re.compile(r"(\d{1,3}\.){3}\d{1,3}"); ure=re.compile(r"(?:Invalid user|user)\s+([A-Za-z0-9._-]+)")
for f in AUTH_LOGS:
if not os.path.exists(f): continue
try:
st=os.stat(f); ino=str(st.st_ino); start=offsets.get(ino,0)
if start>st.st_size: start=0 # rotated/truncated
with open(f,errors="ignore") as fh:
fh.seek(start)
for line in fh:
if not pat.search(line): continue
m=ipre.search(line)
if not m: continue
ip=m.group(0); t=ts_of(line); att[ip]=att.get(ip,0)+1
if ip not in fs or t<fs[ip]: fs[ip]=t
if ip not in ls or t>ls[ip]: ls[ip]=t
mu=ure.search(line)
if mu: users[ip]=mu.group(1)
new[ino]=fh.tell()
except Exception: pass
return att,fs,ls,users,new
def registry_update(full=False):
"""Incremental: only reads log bytes appended since last run. The persistent
store keeps every IP forever (even after logs rotate)."""
reg=registry_load(); now=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
meta=reg.get("_meta",{}); offsets={} if full else meta.get("offsets",{})
if "_meta" not in reg and registry_ips(reg) and not full:
# pre-incremental store already has counts: mark EOF, go incremental forward
for f in AUTH_LOGS:
if os.path.exists(f): offsets[str(os.stat(f).st_ino)]=os.stat(f).st_size
att,fs,ls,users=({},{},{},{})
else:
att,fs,ls,users,offsets=_scan_authlogs(offsets)
ipre=re.compile(r"(\d{1,3}\.){3}\d{1,3}")
logins={}
for line in sh("last -i -n 300").splitlines():
p=line.split()
if len(p)>=3 and ipre.fullmatch(p[2] or ""): logins[p[2]]=logins.get(p[2],0)+1
banned=set()
for j in f2b_jails(): banned|=set(f2b_status(j)["ips"])
bl=set(blacklist_list())
for ip in set(att)|set(logins)|{i for i,_ in registry_ips(reg)}|banned|bl:
e=reg.get(ip,{})
e["country"]=e.get("country") or geo(ip)
e["attempts"]=e.get("attempts",0)+att.get(ip,0)
if ip in fs: e["first"]=min(e.get("first",fs[ip]),fs[ip])
if ip in ls: e["last"]=max(e.get("last",""),ls[ip])
e["logins"]=max(e.get("logins",0),logins.get(ip,0))
e["banned"]=ip in banned; e["blacklisted"]=ip in bl
if ip in users: e["last_user"]=users[ip]
e.setdefault("first",now); e.setdefault("last",now)
reg[ip]=e
reg["_meta"]={"offsets":offsets,"updated":now}
registry_save(reg); return reg
def _recent(ts, hours=24):
try: return (datetime.now()-datetime.strptime(ts,"%Y-%m-%d %H:%M:%S")).total_seconds() < hours*3600
except Exception: return False
def registry_filter(reg, which):
v=list(reg.items())
if which=="Attackers": v=[(i,e) for i,e in v if e.get("attempts",0)>0 and not e.get("logins")]
elif which=="Banned": v=[(i,e) for i,e in v if e.get("banned") or e.get("blacklisted")]
elif which=="Unbanned": v=[(i,e) for i,e in v if (e.get("attempts",0)>0) and not e.get("banned") and not e.get("blacklisted")]
elif which=="New": v=[(i,e) for i,e in v if _recent(e.get("first",""))]
elif which=="Logins": v=[(i,e) for i,e in v if e.get("logins",0)>0]
key={"Attackers":"attempts","Logins":"logins"}.get(which)
v.sort(key=lambda x:-(x[1].get(key,0)) if key else x[1].get("last",""), reverse=bool(not key))
if key: v.sort(key=lambda x:-x[1].get(key,0))
return v
# --------------------------------------------------------------------------- ssh / users
def ssh_sessions():
s=[]
for line in sh("who").splitlines():
p=line.split()
if len(p)>=5:
frm=p[-1].strip("()")
s.append({"user":p[0],"tty":p[1],"from":frm,"country":geo(frm) if re.match(r"\d+\.\d+\.\d+\.\d+",frm) else "local","since":" ".join(p[2:4])})
return s
def ssh_info():
t=sh("sshd -T",6)
g=lambda k:next((l.split(None,1)[1] for l in t.splitlines() if l.lower().startswith(k)),"?")
recent=[l for l in sh("last -a -n 14").splitlines() if l.strip() and not l.startswith("wtmp")]
return {"PermitRootLogin":g("permitrootlogin"),"PasswordAuthentication":g("passwordauthentication"),
"PubkeyAuthentication":g("pubkeyauthentication"),"Port":g("port"),
"sessions":ssh_sessions(),"recent":recent[:12],"attacker_ips":len(attackers(99999))}
def my_tty():
try: return os.ttyname(sys.stdin.fileno()).replace("/dev/","")
except Exception: return ""
def my_ip(): return os.environ.get("SSH_CLIENT","").split()[0] if os.environ.get("SSH_CLIENT") else ""
def kick_tty(tty):
if tty==my_tty(): return "refused: that is YOUR session"
subprocess.run(["pkill","-KILL","-t",tty]); return f"kicked session {tty}"
def kick_ip(ip):
if ip==my_ip(): return "refused: that is YOUR IP"
killed=[]
for s in ssh_sessions():
if s["from"]==ip and s["tty"]!=my_tty():
subprocess.run(["pkill","-KILL","-t",s["tty"]]); killed.append(s["tty"])
return f"kicked {ip}: "+(", ".join(killed) if killed else "no live session")
def users_list():
sudoers=set()
for grp in ("sudo","admin","wheel"):
m=sh(f"getent group {grp}")
if m and ":" in m: sudoers|=set(filter(None,m.split(":")[-1].split(",")))
live={}
for line in sh("who").splitlines():
u=line.split()[0] if line.split() else ""
live[u]=live.get(u,0)+1
rows=[]
for line in sh("getent passwd").splitlines():
p=line.split(":")
if len(p)<7: continue
name,uid,home,shell=p[0],int(p[2]),p[5],p[6]
if not(uid==0 or uid>=1000) or name=="nobody" or shell.endswith(("nologin","false")): continue
ll=sh(f"lastlog -u {name}").splitlines()
last=ll[1].split(None,3)[-1] if len(ll)>1 and "Never" not in ll[1] else "Never"
locked="L" in (sh(f"passwd -S {name}").split()[1:2] or [""])[0]
rows.append({"user":name,"uid":uid,"sudo":name in sudoers or uid==0,"shell":shell,
"lastlogin":last,"locked":locked,"key":os.path.exists(f"{home}/.ssh/authorized_keys"),
"live":live.get(name,0),"groups":sh(f"id -nG {name}")})
return rows
# --------------------------------------------------------------------------- traffic series
def vnstat_series(mode):
flag={"24h":"h","7d":"d","30d":"d"}.get(mode,"d")
try: j=json.loads(sh_raw(f"vnstat --json {flag} -i {iface()}",8) or "{}")
except Exception: j={}
out=[]
try:
node=j["interfaces"][0]["traffic"]
arr=node.get("hour") or node.get("hours") or node.get("day") or node.get("days") or []
for e in arr[-(24 if mode=="24h" else (7 if mode=="7d" else 30)):]:
d=e.get("date",{}); lab=(f"{d.get('hour',e.get('time',{}).get('hour','')):>2}h" if mode=="24h"
else f"{d.get('month','')}/{d.get('day','')}")
out.append({"label":lab,"rx":e.get("rx",0),"tx":e.get("tx",0)})
except Exception: pass
return out
def vnstat_oneline():
p=sh(f"vnstat --oneline -i {iface()}").split(";")
return {"today":p[5],"rx":p[3],"tx":p[4],"rate":p[6],"month":p[10],"all":p[13]} if len(p)>=14 else {}
# --------------------------------------------------------------------------- access log
def accesslog_status():
on="accesslog" in sh(f"docker inspect {TRAEFIK} --format "+"{{json .Args}}").lower()
return {"enabled":on,"logfile":os.path.exists(ACCESS_LOG)}
def perdomain():
if not os.path.exists(ACCESS_LOG): return None
reqs,byts={},{}
try:
with open(ACCESS_LOG,errors="ignore") as fh:
for line in fh:
try: jj=json.loads(line)
except Exception: continue
h=jj.get("RequestHost","?"); reqs[h]=reqs.get(h,0)+1; byts[h]=byts.get(h,0)+int(jj.get("DownstreamContentSize",0) or 0)
except Exception: return None
return sorted([{"domain":d,"reqs":reqs[d],"bytes":byts.get(d,0)} for d in reqs],key=lambda x:-x["reqs"])
# =========================================================================== CLI
def cli(argv):
cmd=argv[0] if argv else "status"; a=argv[1:]
if cmd in("status","overview"):
o=overview(); svc=services(); up=sum(1 for s in svc if s["status"].startswith("Up"))
print(f"AVNI Cloud — {o['host']} ({o['ip']})")
print(f" OS {o['os']} · kernel {o['kernel']}")
print(f" Uptime {o['uptime']} · Load {' '.join(o['load'])} (cpus {o['ncpu']})")
print(f" RAM {o['mem_used']}/{o['mem_total']}MB · Disk {o['disk_used']}/{o['disk_size']} ({o['disk_pct']})")
print(f" Services {up}/{len(svc)} up · Domains {len(traefik_domains())} · Banned {f2b_status('sshd')['banned']} · Blacklist {blacklist_count()}")
elif cmd=="services":
for s in services():
print(f" {'' if s['status'].startswith('Up') else ''} {s['name']:<34}{s['status']:<26}{s['domain']}")
elif cmd=="domains":
for d,c in sorted(traefik_domains().items()):
print(f" {d:<32} HTTP {sh(f'curl -s -m8 -o /dev/null -w %{{http_code}} https://{d}/'):<4} cert:{cert_expiry(d):<22} -> {c}")
elif cmd=="traffic":
win=a[0] if a else "7d"; ol=vnstat_oneline()
print(f" today {ol.get('today','?')} (rx {ol.get('rx','')}/tx {ol.get('tx','')}) rate {ol.get('rate','')} · month {ol.get('month','')}")
for e in vnstat_series(win):
tot=e["rx"]+e["tx"]; print(f" {e['label']:>6} {human(tot):>8} {bar(tot,max((x['rx']+x['tx']) for x in vnstat_series(win)),30)}")
print(" per-container:"); [print(f" {r['name']:<32}{r['net']}") for r in docker_net()]
elif cmd=="perdomain":
rows=perdomain()
if rows is None: print(" needs access log — run: avni accesslog on")
else:
for r in rows: print(f" {r['domain']:<32}{r['reqs']:>9} req {human(r['bytes']):>9}")
elif cmd=="bans":
for j in f2b_jails():
s=f2b_status(j); print(f" [{j}] banned={s['banned']} total={s['total']}")
for ip in s["ips"]: print(f" {ip:<18}{geo(ip)}")
elif cmd=="attackers":
n=int(a[0]) if a and a[0].isdigit() else 15; rows=attackers(n); mx=rows[0]["count"] if rows else 1
for r in rows: print(f" {r['count']:>7} {r['ip']:<17}{geo(r['ip']):<16}{bar(r['count'],mx,24)}")
elif cmd=="ipinfo":
if not a: sys.exit("usage: avni ipinfo <ip>")
d=ip_intel(a[0])
print(f" IP {d['ip']} ({d['country']})")
print(f" attempts {d['attempts']} · first {d['first']} · last {d['last']}")
print(f" banned={d['banned']} blacklisted={d['blacklisted']} org={d['org']}")
print(f" usernames tried: "+", ".join(f"{u}({c})" for u,c in d['users']))
elif cmd=="ips":
which=a[0].capitalize() if a else "All"
reg=registry_update(); rows=registry_filter(reg,which) if which!="All" else sorted(reg.items(),key=lambda x:-x[1].get("attempts",0))
print(f" {which}: {len(rows)} IPs (total tracked: {len(reg)})")
print(f" {'IP':<16}{'COUNTRY':<14}{'ATT':>7}{'LOGIN':>6} STATUS LAST")
for ip,e in rows[:60]:
st="BLACKLIST" if e.get("blacklisted") else ("BANNED" if e.get("banned") else ("login" if e.get("logins") else "seen"))
print(f" {ip:<16}{(e.get('country','?') or '?')[:13]:<14}{e.get('attempts',0):>7}{e.get('logins',0):>6} {st:<10} {e.get('last','')}")
elif cmd=="geo": print(geo(a[0]) if a else "usage: avni geo <ip>")
elif cmd=="ban": print(ban(a[0],a[1] if len(a)>1 else "sshd"),"banned",a[0]) if a else sys.exit("usage")
elif cmd=="unban": print(unban(a[0],a[1] if len(a)>1 else "sshd"),"unbanned",a[0]) if a else sys.exit("usage")
elif cmd=="blacklist":
if not a: [print(" "+x+" "+geo(x.split('/')[0])) for x in blacklist_list()] or (print(" (empty)") if not blacklist_list() else None)
elif a[0]=="add" and len(a)>1: print(blacklist_add(a[1]))
elif a[0]=="del" and len(a)>1: print(blacklist_del(a[1]))
else: print("usage: avni blacklist [add|del <ip|cidr|domain>]")
elif cmd=="whitelist": [print(f" [{j}] {f2b_ignoreip(j)}") for j in f2b_jails()]
elif cmd=="whois": print(whois_ip(a[0]) if a else "usage: avni whois <ip>")
elif cmd=="ssh":
s=ssh_info()
print(f" PermitRootLogin={s['PermitRootLogin']} PasswordAuth={s['PasswordAuthentication']} Pubkey={s['PubkeyAuthentication']} Port={s['Port']}")
print(f" attacker IPs in auth.log: {s['attacker_ips']}"); print(" live sessions:")
for x in s["sessions"]: print(f" {x['user']}@{x['tty']:<8} {x['from']:<16}({x['country']}) since {x['since']}")
print(" recent:"); [print(" "+r) for r in s["recent"]]
elif cmd=="kick": print(kick_ip(a[0]) if a and re.match(r"\d+\.\d+\.\d+\.\d+",a[0]) else (kick_tty(a[0]) if a else "usage: avni kick <ip|pts/N>"))
elif cmd=="users":
print(f" {'USER':<12}{'UID':>5} SUDO LIVE LOCK KEY LASTLOGIN")
for u in users_list():
print(f" {u['user']:<12}{u['uid']:>5} {'yes ' if u['sudo'] else '- '} {u['live']:>3} {'L' if u['locked'] else '-'} {'y' if u['key'] else 'n'} {u['lastlogin']}")
elif cmd=="accesslog": accesslog_cmd(a[0] if a else "status")
else: print(__doc__)
def accesslog_cmd(sub):
st=accesslog_status()
if sub=="status": print(f" enabled={st['enabled']} logfile={st['logfile']} path={ACCESS_LOG}"); return
print(" Enable per-domain stats (recreates the Appwrite Traefik ingress):")
print(" 1) <your-traefik-stack>/docker-compose.yml traefik command, add:")
print(" - --accesslog=true\n - --accesslog.filepath=/var/log/traefik/access.log\n - --accesslog.format=json")
print(" volumes: - /var/log/traefik:/var/log/traefik")
print(" 2) cd <your-traefik-stack> && docker compose up -d traefik 3) avni perdomain")
# =========================================================================== TUI
SECTIONS=["Overview","Services","Domains","Traffic","Security","SSH","Users"]
def init_colors():
curses.start_color(); curses.use_default_colors()
C=lambda n,f:curses.init_pair(n,f if curses.COLORS>=256 else f%8,-1)
cream=223 if curses.COLORS>=256 else 3
curses.init_pair(1,cream,-1); curses.init_pair(2,234 if curses.COLORS>=256 else 0,cream)
curses.init_pair(3,71 if curses.COLORS>=256 else 2,-1); curses.init_pair(4,203 if curses.COLORS>=256 else 1,-1)
curses.init_pair(5,245 if curses.COLORS>=256 else 7,-1); curses.init_pair(6,curses.COLOR_WHITE,-1)
curses.init_pair(7,214 if curses.COLORS>=256 else 3,-1); curses.init_pair(8,110 if curses.COLORS>=256 else 6,-1)
class TUI:
def __init__(self,scr):
self.scr=scr; self.sec=0; self.sel=0; self.tw=0
self.msg="Tab/←→ sections · ↑↓ select · Enter detail · keys in footer · q quit"; self.cache={}
def data(self,k,fn,ttl=4):
now=time.time()
if k not in self.cache or now-self.cache[k][0]>ttl:
try: self.cache[k]=(now,fn())
except Exception: self.cache[k]=(now,[])
return self.cache[k][1]
def rows(self):
s=SECTIONS[self.sec]
if s=="Services": return [(svc_line(r),r) for r in self.data("svc",services)]
if s=="Domains": return [(d,{"domain":d,"cont":c}) for d,c in sorted(self.data("dom",traefik_domains,8).items())]
if s=="Security": return self.sec_rows()
if s=="SSH": return [(None,x) for x in self.data("ssh",ssh_info,5)["sessions"]]
if s=="Users": return [(None,u) for u in self.data("usr",users_list,10)]
return []
def sec_rows(self):
atk=self.data("atk",lambda:attackers(14),12); mx=atk[0]["count"] if atk else 1
rows=[]
for r in atk:
rows.append((None,{"kind":"atk","ip":r["ip"],"count":r["count"],"mx":mx}))
return rows
def clampsel(self,n): self.sel=max(0,min(self.sel,n-1)) if n else 0
def draw(self):
scr=self.scr; scr.erase(); h,w=scr.getmaxyx()
scr.attron(curses.color_pair(2)); scr.addstr(0,0," "*(w-1))
scr.addstr(0,1,"AVNI CLOUD · control center"[:w-2]); scr.attroff(curses.color_pair(2))
clk=datetime.now().strftime("%H:%M:%S")
if w>12: scr.addstr(0,w-len(clk)-1,clk,curses.color_pair(1))
x=0
for i,s in enumerate(SECTIONS):
lab=f" {i+1}.{s} "
if x+len(lab)>=w: break
scr.addstr(1,x,lab,curses.color_pair(2) if i==self.sec else curses.color_pair(5)); x+=len(lab)
scr.hline(2,0,curses.ACS_HLINE,w-1)
try: self.body(scr,3,h-2,w)
except Exception as e: scr.addstr(4,2,f"(render: {e})"[:w-3],curses.color_pair(4))
scr.attron(curses.color_pair(2)); scr.addstr(h-1,0," "*(w-1))
scr.addstr(h-1,1,self.msg[:max(0,w-22)]); scr.attroff(curses.color_pair(2))
foot=self.footer()
if w>len(foot)+2: scr.addstr(h-1,w-len(foot)-1,foot,curses.color_pair(2))
scr.refresh()
def gauge(self,scr,y,x,label,used,total,w,unit=""):
pct=(used/total) if total else 0
col=3 if pct<0.7 else (7 if pct<0.9 else 4)
scr.addstr(y,x,f"{label:<8}",curses.color_pair(1))
bw=max(8,w-30); b=bar(used,total,bw)
fill=int(round(bw*min(pct,1)))
scr.addstr(y,x+9,b[:fill],curses.color_pair(col)); scr.addstr(y,x+9+fill,b[fill:],curses.color_pair(5))
scr.addstr(y,x+10+bw,f"{pct*100:4.0f}% {used}/{total}{unit}"[:24],curses.color_pair(6))
def body(self,scr,top,bot,w):
s=SECTIONS[self.sec]
if s=="Overview": return self.b_overview(scr,top,bot,w)
if s=="Traffic": return self.b_traffic(scr,top,bot,w)
if s=="SSH": return self.b_ssh(scr,top,bot,w)
if s=="Users": return self.b_users(scr,top,bot,w)
if s=="Security": return self.b_security(scr,top,bot,w)
rows=self.rows(); self.clampsel(len(rows))
if not rows: scr.addstr(top+1,2,"(nothing)",curses.color_pair(5)); return
view=bot-top-1; start=max(0,self.sel-view+1)
for i,(line,obj) in enumerate(rows[start:start+view]):
idx=start+i; y=top+1+i; txt=line if line else str(obj)
if SECTIONS[self.sec]=="Domains": txt=f"{obj['domain']:<34}{obj['cont']}"
scr.addstr(y,2,(txt[:w-4]).ljust(w-4) if idx==self.sel else txt[:w-4],
curses.color_pair(2) if idx==self.sel else color_for(txt))
def b_overview(self,scr,top,bot,w):
o=self.data("ov",overview,5); svc=self.data("svc",services); up=sum(1 for x in svc if x["status"].startswith("Up"))
ol=self.data("vol",vnstat_oneline,6)
info=[("Host",f"{o['host']} {o['ip']}"),("OS",f"{o['os']} · {o['kernel']}"),("Uptime",o["uptime"])]
for i,(k,v) in enumerate(info):
scr.addstr(top+1+i,2,f"{k:<8}",curses.color_pair(1)); scr.addstr(top+1+i,11,str(v)[:w-13],curses.color_pair(6))
y=top+5
la=float(o["load"][0]); self.gauge(scr,y,2,"Load",round(la,2),o["ncpu"],w,"")
self.gauge(scr,y+1,2,"Memory",o["mem_used"],o["mem_total"],w,"MB")
dpct=int(re.sub(r"\D","",o["disk_pct"]) or 0); self.gauge(scr,y+2,2,"Disk",dpct,100,w,"%")
y+=4
scr.addstr(y,2,f"Services {up}/{len(svc)} up Domains {len(self.data('dom',traefik_domains,8))} "
f"Banned {f2b_status('sshd')['banned']} Blacklist {self.data('blc',blacklist_count,10)}",curses.color_pair(1))
scr.addstr(y+2,2,f"Net today {ol.get('today','?')} (rx {ol.get('rx','')} / tx {ol.get('tx','')}) rate {ol.get('rate','')} · month {ol.get('month','')}",curses.color_pair(6))
att=self.data("atk",lambda:attackers(14),12)
scr.addstr(y+4,2,"Attack pressure (top IPs): "+spark([a["count"] for a in att]),curses.color_pair(4))
def b_traffic(self,scr,top,bot,w):
win=["24h","7d","30d"][self.tw%3]
scr.addstr(top+1,2,f"Window < {win} > (↑↓ switches 24h / 7d / 30d)",curses.color_pair(1))
ser=self.data("vn"+win,lambda:vnstat_series(win),20)
ol=self.data("vol",vnstat_oneline,6)
scr.addstr(top+2,2,f"today {ol.get('today','?')} rate {ol.get('rate','')} month {ol.get('month','')}",curses.color_pair(6))
y=top+4; mx=max((e["rx"]+e["tx"]) for e in ser) if ser else 1
for e in ser[-(bot-top-6):]:
tot=e["rx"]+e["tx"]
scr.addstr(y,2,f"{e['label']:>6} {human(tot):>8} ",curses.color_pair(6))
bw=max(6,w-40); scr.addstr(y,18,bar(tot,mx,bw),curses.color_pair(8)); y+=1
if y>=bot-1: break
if not ser: scr.addstr(y,2,"(vnstat is still collecting — history fills in over the coming days)",curses.color_pair(5))
def b_ssh(self,scr,top,bot,w):
s=self.data("ssh",ssh_info,5); warn=s["PasswordAuthentication"]=="yes"
scr.addstr(top+1,2,f"PermitRootLogin {s['PermitRootLogin']} PasswordAuth {s['PasswordAuthentication']} Pubkey {s['PubkeyAuthentication']} Port {s['Port']}",
curses.color_pair(4 if warn else 3))
scr.addstr(top+2,2,f"distinct attacker IPs in auth.log: {s['attacker_ips']}",curses.color_pair(1))
scr.addstr(top+4,2,"LIVE SESSIONS (↑↓ select · k kick session · K kick+blacklist IP):",curses.color_pair(1))
sess=s["sessions"]; self.clampsel(len(sess)); y=top+5
for i,x in enumerate(sess):
mark="»" if i==self.sel else " "; me=" (you)" if x["tty"]==my_tty() else ""
line=f"{mark} {x['user']}@{x['tty']:<9}{x['from']:<16} {x['country']:<14} since {x['since']}{me}"
scr.addstr(y,2,line[:w-4],curses.color_pair(2) if i==self.sel else curses.color_pair(6)); y+=1
y+=1; scr.addstr(y,2,"recent logins:",curses.color_pair(1)); y+=1
for r in s["recent"]:
if y>=bot-1: break
scr.addstr(y,4,r[:w-6],curses.color_pair(5)); y+=1
def b_users(self,scr,top,bot,w):
us=self.data("usr",users_list,10); self.clampsel(len(us))
scr.addstr(top+1,2,f"{'USER':<12}{'UID':>5} SUDO LIVE LOCK KEY LASTLOGIN (↑↓ · L lock · Uu unlock)",curses.color_pair(1))
y=top+2
for i,u in enumerate(us):
mark="»" if i==self.sel else " "
line=(f"{mark}{u['user']:<12}{u['uid']:>5} {'yes' if u['sudo'] else ' - ':<4} {u['live']:>3} "
f"{'L' if u['locked'] else '-'} {'y' if u['key'] else 'n'} {u['lastlogin']}")
scr.addstr(y,2,line[:w-4],curses.color_pair(2) if i==self.sel else (curses.color_pair(4) if u['locked'] else curses.color_pair(6))); y+=1
if self.sel<len(us):
u=us[self.sel]; scr.addstr(bot-1,2,f"groups: {u['groups']}"[:w-4],curses.color_pair(5))
def b_security(self,scr,top,bot,w):
banned=self.data("ban",f2b_banned_all,4); bl=self.data("blc2",blacklist_list,8)
scr.addstr(top+1,2,f"fail2ban banned: {len(banned)} permanent blacklist: {len(bl)} "
"(b ban · B blacklist · u unban · w whois · Enter detail · g blacklist view)",curses.color_pair(1))
scr.addstr(top+3,2,"TOP ATTACKERS (attempts · country):",curses.color_pair(1))
rows=self.sec_rows(); self.clampsel(len(rows)); y=top+4
for i,(_,o) in enumerate(rows[:bot-top-5]):
mark="»" if i==self.sel else " "
bnd="●BL" if (subprocess.run(["ipset","test","avni_blacklist",o["ip"]],capture_output=True).returncode==0) else ("●b" if any(o["ip"] in f2b_status(j)["ips"] for j in ["sshd"]) else " ")
line=f"{mark}{o['count']:>7} {o['ip']:<16}{geo(o['ip']):<14}{bnd:<4}"
scr.addstr(y,2,line[:w-34],curses.color_pair(2) if i==self.sel else curses.color_pair(6))
scr.addstr(y,min(w-30,2+len(line)),bar(o["count"],o["mx"],24),curses.color_pair(4)); y+=1
def footer(self):
s=SECTIONS[self.sec]
return {"Security":"b ban·B blacklist·u unban·w whois·Enter detail·r·q",
"Services":"s start·x stop·R restart·l logs·r·q",
"SSH":"k kick·K kick+blacklist·r·q","Users":"L lock·U unlock·r·q",
"Domains":"Enter detail·r·q","Traffic":"↑↓ window·r·q"}.get(s,"r refresh·q quit")
def detail_ip(self,ip):
d=ip_intel(ip)
lines=[f"IP {d['ip']} ({d['country']})",
f"attempts {d['attempts']}",
f"first {d['first']}", f"last {d['last']}",
f"banned {d['banned']} blacklisted {d['blacklisted']}",
f"org {d['org']}",
"usernames "+", ".join(f"{u}({c})" for u,c in d["users"]) or "usernames —","",
"[b]an temp [B]lacklist permanent [u]nban [w]hois [any] close"]
ch=self.popup(f"IP intel · {ip}",lines,wait=True)
if ch in (ord('b'),): self.msg="ban "+ip+": "+(ban(ip) or "ok"); self.cache.pop("ban",None)
elif ch==ord('B'): self.msg=blacklist_add(ip); self.cache.pop("blc2",None)
elif ch==ord('u'): self.msg="unban "+ip+": "+(unban(ip) or "ok"); self.cache.pop("ban",None)
elif ch==ord('w'): self.popup("whois "+ip,whois_ip(ip).splitlines(),wait=True)
def act(self,ch):
s=SECTIONS[self.sec]; rows=self.rows(); obj=rows[self.sel][1] if rows and self.sel<len(rows) else None
if s=="Traffic" and ch in (curses.KEY_UP,curses.KEY_DOWN,ord('j'),ord('k')):
self.tw+=1 if ch in (curses.KEY_DOWN,ord('j')) else -1; return
if s=="Security":
o=self.sec_rows()[self.sel][1] if self.sec_rows() and self.sel<len(self.sec_rows()) else None
ip=o["ip"] if o else None
if ch in (curses.KEY_ENTER,10,13) and ip: self.detail_ip(ip)
elif ch==ord('b') and ip: self.msg="ban "+ip+": "+(ban(ip) or "ok"); self.cache.pop("ban",None)
elif ch==ord('B') and ip: self.msg=blacklist_add(ip); self.cache.pop("blc2",None); self.cache.pop("blc",None)
elif ch==ord('u') and ip: self.msg="unban "+ip+": "+(unban(ip) or "ok"); self.cache.pop("ban",None)
elif ch==ord('w') and ip: self.popup("whois "+ip,whois_ip(ip).splitlines(),wait=True)
elif ch==ord('g'): self.popup("Permanent blacklist",[x+" "+geo(x.split('/')[0]) for x in blacklist_list()] or ["(empty)"],wait=True)
elif ch==ord('a'):
v=self.prompt("ban/blacklist IP or domain: ")
if v: self.msg=blacklist_add(v); self.cache.pop("blc2",None)
elif s=="Services" and obj:
n=obj["name"]
if ch==ord('R'): self.msg=f"restarting {n}"; sh(f"docker restart {n}",40); self.cache.pop("svc",None); self.msg=f"restarted {n}"
elif ch==ord('x'): sh(f"docker stop {n}",40); self.cache.pop("svc",None); self.msg=f"stopped {n}"
elif ch==ord('s'): sh(f"docker start {n}",40); self.cache.pop("svc",None); self.msg=f"started {n}"
elif ch==ord('l'): self.popup("logs "+n,sh(f"docker logs --tail 60 {n}",10).splitlines(),wait=True)
elif s=="SSH" and obj:
if ch==ord('k'): self.msg=kick_tty(obj["tty"]); self.cache.pop("ssh",None)
elif ch==ord('K'): self.msg=kick_ip(obj["from"])+" + "+blacklist_add(obj["from"]); self.cache.pop("ssh",None)
elif s=="Domains" and obj and ch in (curses.KEY_ENTER,10,13):
d=obj["domain"]; code=sh(f"curl -s -m8 -o /dev/null -w %{{http_code}} https://{d}/")
self.popup("domain · "+d,[f"backend {obj['cont']}",f"HTTP {code}",f"cert exp {cert_expiry(d)}",
f"IPs {sh('getent ahostsv4 '+d).splitlines()[0].split()[0] if sh('getent ahostsv4 '+d) else '?'}"],wait=True)
elif s=="Users" and obj:
if ch==ord('L') and obj["user"] not in("root",): sh(f"usermod -L {obj['user']}"); self.cache.pop("usr",None); self.msg=f"locked {obj['user']}"
elif ch in (ord('U'),ord('u')): sh(f"usermod -U {obj['user']}"); self.cache.pop("usr",None); self.msg=f"unlocked {obj['user']}"
def prompt(self,label):
curses.echo(); curses.curs_set(1); h,w=self.scr.getmaxyx()
self.scr.addstr(h-1,0," "*(w-1),curses.color_pair(2)); self.scr.addstr(h-1,1,label,curses.color_pair(2))
try: v=self.scr.getstr(h-1,1+len(label),50).decode().strip()
except Exception: v=""
curses.noecho(); curses.curs_set(0); return v
def popup(self,title,lines,wait=False):
h,w=self.scr.getmaxyx(); win=curses.newwin(min(h-4,len(lines)+4),w-6,2,3); win.box()
win.addstr(0,2,f" {title} ",curses.color_pair(1))
for i,l in enumerate(lines[:h-7]):
try: win.addstr(1+i,2,str(l)[:w-10],curses.color_pair(6))
except Exception: pass
win.refresh(); ch=win.getch() if wait else -1; return ch
def loop(self):
curses.curs_set(0); self.scr.timeout(1500)
while True:
self.draw(); ch=self.scr.getch()
if ch==-1: continue
if ch in (ord('q'),27): break
elif ch in (curses.KEY_RIGHT,9): self.sec=(self.sec+1)%len(SECTIONS); self.sel=0
elif ch==curses.KEY_LEFT: self.sec=(self.sec-1)%len(SECTIONS); self.sel=0
elif ord('1')<=ch<=ord('7'): self.sec=ch-ord('1'); self.sel=0
elif ch in (curses.KEY_DOWN,) and SECTIONS[self.sec]!="Traffic": self.sel+=1
elif ch in (curses.KEY_UP,) and SECTIONS[self.sec]!="Traffic": self.sel=max(0,self.sel-1)
elif ch==ord('r'): self.cache.clear(); self.msg="refreshed"
else: self.act(ch)
def svc_line(r):
return f"{'' if r['status'].startswith('Up') else ''} {r['name']:<32}{r['status']:<24}{(' '+r['domain']) if r['domain'] else ''}"
def color_for(line):
if line.startswith("") or "unhealthy" in line: return curses.color_pair(4)
if line.startswith(""): return curses.color_pair(3)
return curses.color_pair(6)
def tui_main(scr): init_colors(); TUI(scr).loop()
def main():
if len(sys.argv)>1: cli(sys.argv[1:])
elif not sys.stdout.isatty(): print("avni: not a TTY. Try a subcommand (avni status) or run in a terminal."); sys.exit(1)
else: curses.wrapper(tui_main)
if __name__=="__main__": main()

119
docs/guide.md Normal file
View File

@@ -0,0 +1,119 @@
# AVNI Cloud Control Center (`avni`)
A single command to see and manage the whole VPS. **Rewritten in Rust/Ratatui**
(16-06-2026) for speed — ~2% CPU vs the old Python build's ~22%, microsecond
render, incremental log parsing.
- Rust source: `src/main.rs` (`cargo build --release` at the repo root)
- **Deploy/rebuild: `sudo bash install.sh`** (builds + installs the binary +
sets up the access group/sudoers). The binary at `/usr/local/bin/avni` is a **real file**
(root:root 0755) — NOT a symlink, so authorised non-root users can exec it.
- Python fallback: `avni.py` (optional; install as `/usr/local/bin/avni-py`)
- Firewall blacklist engine: `avni-firewall.sh` (installed to `/usr/local/bin/avni-firewall.sh`)
- IP registry (persistent): `/var/lib/avni/ips.json`
## Who can run it (access control)
`avni` is **restricted to root + members of the `avni` group**. A non-root user who runs it
is auto-elevated by re-exec'ing through `sudo` (drop-in `/etc/sudoers.d/avni`, NOPASSWD for the
`avni` group). A user with no access just gets a "not authorised" message.
**Rule: grant the tool ONLY to users who are already admins (sudo).** Make a user an admin in the
**Users** tab (`G`) first, then grant the tool in the **Settings** tab (`g`). Grant refuses
non-admins. Root can never be revoked.
## Open it
```bash
avni # full-screen TUI (run in a real terminal / SSH session)
```
Keys: `1-9` or `Tab`/`←→` switch tabs · `↑↓` (or `j/k`) select · `r` refresh ·
`q` quit. Tabs: **Overview · Services · Domains · Traffic · Security · SSH · Users · Settings · Apps**.
**Times & IST**: the VPS runs on Europe/Berlin (CEST). Every displayed time has the
**IST equivalent beside it** (e.g. `11:25 (14:55 IST)`); the Overview shows both clocks.
Per-tab actions (shown in the footer):
- **Services**: `s` start · `x` stop · `R` restart · `l` logs
- **Domains**: `Enter` = HTTP code + TLS cert expiry
- **Traffic**: `↑↓` switch window 24h / 7d / 30d (vnstat bar chart)
- **Security**: **`[` `]` switch sub-tabs** · **`a` = ADD blacklist** / **`A` = ADD whitelist**
(type IP/CIDR/domain, Enter confirms, Esc cancels) · `b` temp-ban · `B` blacklist selected ·
`W` whitelist selected · `u` unban / un-whitelist · `w` whois · `Enter` IP intel · `g` list blacklist
- **SSH**: `k` kick session · `K` kick + blacklist its IP
- **Users**: `Enter` **activity** (logins, failed logins, sudo commands, processes — all
system metadata, never reads anyone's home) · `N` new user (type `username` or
`username password`) · `P` **set login password** (Linux/SSH password — type it in the
masked box, min 8 chars, no spaces or `' " \` \ $`) · `G` grant sudo · `g` revoke sudo ·
`L` lock · `U` unlock · `D` delete (confirm with `y`). **root is protected**: its sudo
can't be revoked, and it can't be locked or deleted (its password *can* be set). Delete
removes the **account only** — `/home` and files are kept (safe on this multi-tenant box).
CLI: `avni adduser <u> [pw]` · `avni passwd <u> <pw>` · `avni sudo <u>` · `avni unsudo <u>` · `avni deluser <u>` · `avni activity <u>`.
- **Settings**: who may use `avni`. `g` grant the tool to the selected user · `x` revoke ·
`Enter` activity. **A user must already be an admin (sudo)** — grant refuses non-admins.
Root always has access and can't be revoked. CLI: `avni access` · `avni grant <u>` · `avni revoke <u>`.
- **Apps** (Applications): the user-facing apps Traefik routes — auto-discovered from each
container's `Host()` label, so the list never goes stale. Shows STATE (enabled/disabled),
SUBDOMAIN, internal PORT and published HOST PORTS. `s` enable (start) · `x` disable (stop) ·
`R` restart · `l` logs · `Enter` details (live HTTPS code + TLS cert expiry + compose dir).
CLI: `avni apps`. (Appwrite itself isn't listed — it's the Traefik host / catch-all, managed
via its own stack, and you wouldn't want a one-key "disable" on the thing that owns 80/443.)
## Security tab — the IP registry (CrowdSec-inspired)
Every IP that has touched the box is stored in `/var/lib/avni/ips.json` with
country, attempts, logins, first/last seen, ban/blacklist status — and it
**persists across log rotation**. **Switch sub-tabs with `[` and `]`** (the active
one is highlighted in the cream bar):
`Attackers · Banned · Whitelist · Unbanned · New · Logins · All`.
Three kinds of decision:
- **Temp ban** (`b`) — fail2ban, expires after the jail bantime.
- **Permanent blacklist** (`a` to type / `B` on selected / `K` from SSH) — ipset +
iptables at the firewall, covers host **and** Docker-published ports, survives
reboot, never expires. Remove: `avni-firewall.sh del <ip>`.
- **Whitelist** (`A` to type / `W` on selected) — an IP that can **never** be banned
or blacklisted, even under brute force. It's added to fail2ban `ignoreip` AND a
firewall guard refuses to blacklist it. **Whitelist your own home/office IPs first**
so a brute-force burst can never lock you out. Anti-lockout also refuses to blacklist
your current SSH client IP. CLI: `avni whitelist [add|del <ip>]`.
The UI loads data in a **background thread**, so typing in the add box and switching
tabs stay instant even while it refreshes. **Actions reflect instantly** — grant/revoke,
lock, ban/blacklist/whitelist update the on-screen row immediately (optimistic), then the
background re-gather reconciles with the real system state.
## CLI (scriptable / same data)
```bash
avni status # host + counts
avni services # ALL containers + status + domain
avni apps # user-facing apps only: state · subdomain · port · host ports
avni ips [attackers|banned|unbanned|new|logins|all]
avni attackers [N] # top N with country
avni ban <ip> | unban <ip>
avni blacklist [add <ip|cidr|domain>]
avni geo <ip> | ssh | users
avni adduser <user> [password] # create account (-m home, bash); root-safe
avni passwd <user> <password> # set a user's Linux login / SSH password (root-safe)
avni sudo <user> | unsudo <user> # grant / revoke sudo (refuses root)
avni deluser <user> # delete account, KEEP /home (refuses root)
```
## Speed design (inspired by k9s / lazydocker / ctop / GoAccess / CrowdSec)
- **Loading splash** on startup until the first snapshot lands (no blank shell).
- **Parallel gather**: the independent fetches (docker, auth-log registry, sessions,
users, vnstat, counts) fan out across threads, so a full refresh is ~2 s (the slowest
single part) instead of the ~11 s sum. Public IP is fetched once (was a 4 s curl every refresh).
- **Incremental log parsing**: stores a byte-offset per log inode and reads only
newly-appended lines, not the whole 17 MB `auth.log`, on each refresh.
- **Traffic** pins the default-route interface (eth0) — the box has ~80 docker
veth/bridge interfaces, so vnstat's interfaces[0] was a zero-traffic bridge (empty graph).
All 3 windows (24h/7d/30d) are precomputed, so switching is instant; bars show human values.
- **Cached data**, refreshed every ~10 s or on `r`; rendering is decoupled from
data gathering.
- Shells out to the system tools (docker, fail2ban-client, ipset, vnstat,
geoiplookup, who/last/getent) — no daemon, no heavy deps.
- GoAccess is installed for future per-domain HTTP stats (needs Traefik access
logs; `avni-py accesslog on` prints the opt-in steps).
## Build / rebuild
```bash
cargo build --release # at the repo root
# (toolchain: rustup stable; a distro rustc that is too old for ratatui 0.26 will fail — use rustup)
```

54
install.sh Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Build + install the AVNI control-center, and set up access delegation.
# - binary -> /usr/local/bin/avni (real file, so granted non-root users can exec it)
# - firewall helper -> /usr/local/bin/avni-firewall.sh (used by the blacklist/whitelist engine)
# - 'avni' Unix group = who may use the tool (root always can)
# - sudoers drop-in lets 'avni' members auto-elevate (the binary re-execs via sudo)
#
# Run from anywhere — the script locates its own directory, so the repo can be cloned
# to any path. Re-run after every code change to redeploy. Usage: sudo bash install.sh
set -euo pipefail
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN=/usr/local/bin/avni
FWBIN=/usr/local/bin/avni-firewall.sh
if [ "$(id -u)" -ne 0 ]; then echo "!! run as root: sudo bash install.sh"; exit 1; fi
# locate cargo (rustup install lives under the invoking user's home even under sudo)
CARGO="$(command -v cargo || true)"
[ -z "$CARGO" ] && [ -x "$HOME/.cargo/bin/cargo" ] && CARGO="$HOME/.cargo/bin/cargo"
[ -z "$CARGO" ] && [ -n "${SUDO_USER:-}" ] && [ -x "/home/$SUDO_USER/.cargo/bin/cargo" ] && CARGO="/home/$SUDO_USER/.cargo/bin/cargo"
if [ -z "$CARGO" ]; then echo "!! cargo not found — install Rust via https://rustup.rs"; exit 1; fi
echo "==> building (release)"
( cd "$SRC" && "$CARGO" build --release >/dev/null )
echo "==> 'avni' access group"
groupadd -f avni
echo "==> sudoers drop-in (auto-elevation for granted users)"
cat > /etc/sudoers.d/avni <<'EOF'
# AVNI control-center — members of the 'avni' group may run the tool as root.
# They are already admins (sudo); granting 'avni' just makes `avni` launch seamlessly.
%avni ALL=(root) NOPASSWD: /usr/local/bin/avni
EOF
chmod 440 /etc/sudoers.d/avni
if ! visudo -cf /etc/sudoers.d/avni >/dev/null; then
echo "!! sudoers validation failed — removing drop-in"; rm -f /etc/sudoers.d/avni; exit 1
fi
echo "==> installing firewall helper -> $FWBIN"
install -o root -g root -m 0755 "$SRC/avni-firewall.sh" "$FWBIN"
echo "==> installing binary -> $BIN"
rm -f "$BIN"
install -o root -g root -m 0755 "$SRC/target/release/avni" "$BIN"
# optional: pure-Python fallback
if [ -f "$SRC/avni.py" ]; then
install -o root -g root -m 0755 "$SRC/avni.py" /usr/local/bin/avni-py
fi
echo "OK. root + 'avni' group members can run: avni"
echo "Current avni-group members: $(getent group avni | cut -d: -f4)"

1327
src/main.rs Normal file

File diff suppressed because it is too large Load Diff