#!/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()