mirror of
https://github.com/infinition/Bjorn.git
synced 2025-12-13 08:04:59 +00:00
1063 lines
40 KiB
Python
1063 lines
40 KiB
Python
# webutils/index_utils.py
|
|
from __future__ import annotations
|
|
import os
|
|
import json
|
|
import time
|
|
import socket
|
|
import platform
|
|
import glob
|
|
import subprocess
|
|
import psutil
|
|
import resource
|
|
import logging
|
|
from logger import Logger
|
|
from typing import Optional
|
|
from datetime import datetime
|
|
from typing import Any, Dict, Tuple
|
|
|
|
|
|
# --------- Singleton module (évite recréation à chaque requête) ----------
|
|
logger = Logger(name="index_utils.py", level=logging.DEBUG)
|
|
|
|
|
|
class IndexUtils:
|
|
def __init__(self, shared_data):
|
|
self.shared_data = shared_data
|
|
self.logger = logger
|
|
|
|
self.db = shared_data.db
|
|
|
|
# Cache pour l'assemblage de stats (champs dynamiques)
|
|
self._last_stats: Dict[str, Any] = {}
|
|
self._last_stats_ts: float = 0.0
|
|
self._cache_ttl: float = 5.0 # 5s
|
|
|
|
# Cache pour l'info système (rarement changeant)
|
|
self._system_info_cache: Dict[str, Any] = {}
|
|
self._system_info_ts: float = 0.0
|
|
self._system_cache_ttl: float = 300.0 # 5 min
|
|
|
|
# Cache wardrive (compte Wi-Fi connus)
|
|
self._wardrive_cache_mem: Optional[int] = None
|
|
self._wardrive_ts_mem: float = 0.0
|
|
self._wardrive_ttl: float = 600.0 # 10 min
|
|
|
|
|
|
|
|
def _fds_usage(self):
|
|
try:
|
|
used = len(os.listdir(f"/proc/{os.getpid()}/fd"))
|
|
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
# self.logger.info(f"FD usage: {used} used / {soft} soft limit / {hard} hard limit")
|
|
return used, soft
|
|
except Exception as e:
|
|
# self.logger.debug(f"FD usage error: {e}")
|
|
return 0, 0
|
|
|
|
def _open_fds_count(self) -> int:
|
|
"""Compte le nombre de file descriptors ouverts (proc global)."""
|
|
try:
|
|
return len(glob.glob("/proc/*/fd/*"))
|
|
except Exception as e:
|
|
# self.logger.debug(f"FD probe error: {e}")
|
|
return 0
|
|
|
|
# ---------------------- Helpers JSON ----------------------
|
|
def _to_jsonable(self, obj):
|
|
if obj is None or isinstance(obj, (bool, int, float, str)):
|
|
return obj
|
|
if isinstance(obj, bytes):
|
|
import base64
|
|
return {"_b64": base64.b64encode(obj).decode("ascii")}
|
|
if isinstance(obj, datetime):
|
|
return obj.isoformat()
|
|
if isinstance(obj, dict):
|
|
return {k: self._to_jsonable(v) for k, v in obj.items()}
|
|
if isinstance(obj, (list, tuple, set)):
|
|
return [self._to_jsonable(v) for v in obj]
|
|
return str(obj)
|
|
|
|
def _json(self, handler, code: int, obj):
|
|
payload = json.dumps(self._to_jsonable(obj), ensure_ascii=False).encode("utf-8")
|
|
handler.send_response(code)
|
|
handler.send_header("Content-Type", "application/json")
|
|
handler.send_header("Content-Length", str(len(payload)))
|
|
handler.end_headers()
|
|
try:
|
|
handler.wfile.write(payload)
|
|
except BrokenPipeError:
|
|
pass
|
|
|
|
# ---------------------- Helpers FS ----------------------
|
|
def _read_text(self, path: str) -> Optional[str]:
|
|
try:
|
|
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
return f.read().strip()
|
|
except Exception:
|
|
return None
|
|
|
|
# ---------------------- Config store ----------------------
|
|
def _cfg_get(self, key: str, default=None):
|
|
try:
|
|
row = self.db.query_one("SELECT value FROM config WHERE key=? LIMIT 1;", (key,))
|
|
if not row or row.get("value") is None:
|
|
return default
|
|
raw = row["value"]
|
|
try:
|
|
return json.loads(raw)
|
|
except Exception:
|
|
return raw
|
|
except Exception:
|
|
return default
|
|
|
|
def _cfg_set(self, key: str, value) -> None:
|
|
try:
|
|
s = json.dumps(value, ensure_ascii=False)
|
|
except Exception:
|
|
s = json.dumps(str(value), ensure_ascii=False)
|
|
self.db.execute(
|
|
"""
|
|
INSERT INTO config(key, value) VALUES(?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value
|
|
""",
|
|
(key, s),
|
|
)
|
|
|
|
# ---------------------- Info système ----------------------
|
|
def _get_system_info(self) -> Dict[str, Any]:
|
|
now = time.time()
|
|
if self._system_info_cache and (now - self._system_info_ts) < self._system_cache_ttl:
|
|
return self._system_info_cache
|
|
|
|
os_name, os_ver = self._os_release()
|
|
arch = self._arch_bits()
|
|
model = self._pi_model()
|
|
epd_connected = self._check_epd_connected()
|
|
epd_type = self._cfg_get("epd_type", "epd2in13_V4")
|
|
|
|
self._system_info_cache = {
|
|
"os_name": os_name,
|
|
"os_version": os_ver,
|
|
"arch": arch,
|
|
"model": model,
|
|
"waveshare_epd_connected": epd_connected,
|
|
"waveshare_epd_type": epd_type if epd_connected else None,
|
|
}
|
|
self._system_info_ts = now
|
|
return self._system_info_cache
|
|
|
|
def _os_release(self) -> Tuple[str, str]:
|
|
data = {}
|
|
txt = self._read_text("/etc/os-release") or ""
|
|
for line in txt.splitlines():
|
|
if "=" in line:
|
|
k, v = line.split("=", 1)
|
|
data[k.strip()] = v.strip().strip('"')
|
|
name = data.get("PRETTY_NAME") or data.get("NAME") or platform.system()
|
|
ver = data.get("VERSION_ID") or data.get("VERSION") or platform.version()
|
|
return (name, ver)
|
|
|
|
def _arch_bits(self) -> str:
|
|
try:
|
|
a = platform.architecture()[0]
|
|
return "64-bit" if "64" in a else "32-bit"
|
|
except Exception:
|
|
return "unknown"
|
|
|
|
def _pi_model(self) -> str:
|
|
dt_model = self._read_text("/proc/device-tree/model")
|
|
if dt_model:
|
|
return dt_model.replace("\x00", "").strip()
|
|
return platform.machine()
|
|
|
|
def _check_epd_connected(self) -> bool:
|
|
# I2C puis fallback SPI
|
|
try:
|
|
result = subprocess.run(["i2cdetect", "-y", "1"], capture_output=True, text=True, timeout=1)
|
|
if result.returncode == 0:
|
|
output = result.stdout
|
|
if any(addr in output for addr in ["3c", "3d", "48"]):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return os.path.exists("/dev/spidev0.0")
|
|
|
|
def _uptime_str(self) -> str:
|
|
try:
|
|
up = self._read_text("/proc/uptime")
|
|
seconds = int(float(up.split()[0])) if up else 0
|
|
except Exception:
|
|
seconds = 0
|
|
d, r = divmod(seconds, 86400)
|
|
h, r = divmod(r, 3600)
|
|
m, s = divmod(r, 60)
|
|
return f"{d}d {h:02d}:{m:02d}:{s:02d}" if d else f"{h:02d}:{m:02d}:{s:02d}"
|
|
|
|
def _first_init_ts(self) -> int:
|
|
v = self._cfg_get("first_init_ts")
|
|
if isinstance(v, (int, float)) and v > 0:
|
|
return int(v)
|
|
try:
|
|
row = self.db.query_one(
|
|
"""
|
|
SELECT strftime('%s', MIN(created_at)) AS ts FROM (
|
|
SELECT created_at FROM comments
|
|
UNION SELECT created_at FROM action_queue
|
|
UNION SELECT first_seen FROM hostnames_history
|
|
)
|
|
"""
|
|
)
|
|
if row and row.get("ts"):
|
|
ts = int(row["ts"])
|
|
self._cfg_set("first_init_ts", ts)
|
|
return ts
|
|
except Exception:
|
|
pass
|
|
ts = int(time.time())
|
|
self._cfg_set("first_init_ts", ts)
|
|
return ts
|
|
|
|
# ---------------------- Monitoring ressources ----------------------
|
|
def _cpu_pct(self) -> int:
|
|
try:
|
|
return int(psutil.cpu_percent(interval=0.5))
|
|
except Exception:
|
|
return 0
|
|
|
|
def _mem_bytes(self) -> Tuple[int, int]:
|
|
try:
|
|
vm = psutil.virtual_memory()
|
|
return int(vm.total - vm.available), int(vm.total)
|
|
except Exception:
|
|
try:
|
|
info = self._read_text("/proc/meminfo") or ""
|
|
def kb(k):
|
|
line = next((l for l in info.splitlines() if l.startswith(k + ":")), None)
|
|
return int(line.split()[1]) * 1024 if line else 0
|
|
total = kb("MemTotal")
|
|
free = kb("MemFree") + kb("Buffers") + kb("Cached")
|
|
used = max(0, total - free)
|
|
return used, total
|
|
except Exception:
|
|
return 0, 0
|
|
|
|
def _disk_bytes(self) -> Tuple[int, int]:
|
|
try:
|
|
usage = psutil.disk_usage("/")
|
|
return int(usage.used), int(usage.total)
|
|
except Exception:
|
|
try:
|
|
st = os.statvfs("/")
|
|
total = st.f_frsize * st.f_blocks
|
|
free = st.f_frsize * st.f_bavail
|
|
return int(total - free), int(total)
|
|
except Exception:
|
|
return 0, 0
|
|
|
|
def _battery_probe(self) -> Dict[str, Any]:
|
|
base = "/sys/class/power_supply"
|
|
try:
|
|
if not os.path.isdir(base):
|
|
return {"present": False}
|
|
bat = None
|
|
for n in os.listdir(base):
|
|
if n.startswith("BAT"):
|
|
bat = os.path.join(base, n)
|
|
break
|
|
if not bat:
|
|
return {"present": False}
|
|
cap = self._read_text(os.path.join(bat, "capacity"))
|
|
stat = (self._read_text(os.path.join(bat, "status")) or "Unknown").lower()
|
|
lvl = int(cap) if cap and cap.isdigit() else 0
|
|
if stat.startswith("full"):
|
|
state = "Full"
|
|
elif stat.startswith("char"):
|
|
state = "Charging"
|
|
elif stat.startswith("dis"):
|
|
state = "Discharging"
|
|
else:
|
|
state = "Unknown"
|
|
return {"present": True, "level_pct": max(0, min(100, lvl)), "state": state}
|
|
except Exception as e:
|
|
# self.logger.debug(f"Battery probe error: {e}")
|
|
return {"present": False}
|
|
|
|
# ---------------------- Réseau ----------------------
|
|
def _quick_internet(self, timeout: float = 1.0) -> bool:
|
|
try:
|
|
for server in ["1.1.1.1", "8.8.8.8"]:
|
|
try:
|
|
with socket.create_connection((server, 53), timeout=timeout):
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
def _ip_for(self, ifname: str) -> Optional[str]:
|
|
try:
|
|
result = subprocess.run(
|
|
["ip", "-4", "addr", "show", "dev", ifname], capture_output=True, text=True, timeout=1
|
|
)
|
|
if result.returncode == 0:
|
|
for line in result.stdout.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("inet "):
|
|
return line.split()[1].split("/")[0]
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
def _gw_dns(self) -> Tuple[Optional[str], Optional[str]]:
|
|
gw = None
|
|
dns = None
|
|
try:
|
|
out = subprocess.run(["ip", "route"], capture_output=True, text=True, timeout=1)
|
|
if out.returncode == 0:
|
|
for line in out.stdout.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("default "):
|
|
parts = line.split()
|
|
if "via" in parts:
|
|
idx = parts.index("via")
|
|
if idx + 1 < len(parts):
|
|
gw = parts[idx + 1]
|
|
break
|
|
except Exception:
|
|
pass
|
|
rc = self._read_text("/etc/resolv.conf") or ""
|
|
for line in rc.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("nameserver "):
|
|
dns = line.split()[1]
|
|
break
|
|
return gw, dns
|
|
|
|
def _wifi_ssid(self) -> Optional[str]:
|
|
try:
|
|
out = subprocess.run(["iw", "dev", "wlan0", "link"], capture_output=True, text=True, timeout=1)
|
|
if out.returncode == 0:
|
|
for line in out.stdout.splitlines():
|
|
line = line.strip()
|
|
if line.lower().startswith("ssid:"):
|
|
return line.split(":", 1)[1].strip()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
out = subprocess.run(["wpa_cli", "status"], capture_output=True, text=True, timeout=1)
|
|
if out.returncode == 0:
|
|
for line in out.stdout.splitlines():
|
|
if line.startswith("ssid="):
|
|
return line.split("=", 1)[1]
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
def _detect_gadget_lease(self, prefix: str) -> Optional[str]:
|
|
for iface in ["usb0", "usb1", "rndis0", "eth1", "bnep0", "pan0"]:
|
|
ip = self._ip_for(iface)
|
|
if ip and ip.startswith(prefix):
|
|
return ip
|
|
try:
|
|
out = subprocess.run(["ip", "neigh", "show"], capture_output=True, text=True, timeout=1)
|
|
if out.returncode == 0:
|
|
for line in out.stdout.splitlines():
|
|
parts = line.split()
|
|
if parts and parts[0].startswith(prefix):
|
|
return parts[0]
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
def _bt_connected_device(self) -> Optional[str]:
|
|
try:
|
|
out = subprocess.run(["bluetoothctl", "info"], capture_output=True, text=True, timeout=1)
|
|
if out.returncode == 0:
|
|
for line in out.stdout.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("Name:"):
|
|
return line.split(":", 1)[1].strip()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
# ---------------------- GPS ----------------------
|
|
def _gps_status(self) -> Dict[str, Any]:
|
|
gps_enabled = self._cfg_get("gps_enabled", False)
|
|
if not gps_enabled:
|
|
return {"connected": False, "status": "Not configured"}
|
|
gps_device = self._cfg_get("gps_device", "/dev/ttyUSB0")
|
|
if not os.path.exists(gps_device):
|
|
return {"connected": False, "status": "Device not found"}
|
|
try:
|
|
import gpsd
|
|
gpsd.connect()
|
|
packet = gpsd.get_current()
|
|
return {
|
|
"connected": True,
|
|
"fix_quality": packet.mode,
|
|
"sats": packet.sats,
|
|
"lat": round(packet.lat, 6) if packet.lat else None,
|
|
"lon": round(packet.lon, 6) if packet.lon else None,
|
|
"alt": round(packet.alt, 1) if packet.alt else None,
|
|
"speed": round(packet.hspeed, 1) if packet.hspeed else None,
|
|
}
|
|
except Exception:
|
|
return {"connected": True, "status": "No fix", "fix_quality": 0}
|
|
|
|
# ---------------------- Stats DB (fallback) ----------------------
|
|
def _count_open_ports_total_db(self) -> int:
|
|
try:
|
|
row = self.db.query_one("SELECT COUNT(*) AS c FROM ports WHERE state='open';")
|
|
return int(row["c"]) if row else 0
|
|
except Exception:
|
|
return 0
|
|
|
|
def _alive_hosts_db(self) -> Tuple[int, int]:
|
|
try:
|
|
row = self.db.query_one(
|
|
"""
|
|
SELECT
|
|
SUM(CASE WHEN alive=1 THEN 1 ELSE 0 END) AS alive,
|
|
COUNT(*) AS total
|
|
FROM hosts
|
|
"""
|
|
)
|
|
if row:
|
|
return int(row["alive"] or 0), int(row["total"] or 0)
|
|
except Exception:
|
|
pass
|
|
return 0, 0
|
|
|
|
def _vulns_total_db(self) -> int:
|
|
try:
|
|
row = self.db.query_one("SELECT COUNT(*) AS c FROM vulnerabilities WHERE is_active=1;")
|
|
return int(row["c"]) if row else 0
|
|
except Exception:
|
|
return 0
|
|
|
|
def _credentials_count_db(self) -> int:
|
|
try:
|
|
row = self.db.query_one("SELECT COUNT(*) AS c FROM creds;")
|
|
return int(row["c"]) if row else 0
|
|
except Exception:
|
|
return 0
|
|
|
|
def _files_count_fs(self) -> int:
|
|
try:
|
|
data_dir = "/home/bjorn/Bjorn/data/output/data_stolen"
|
|
if os.path.exists(data_dir):
|
|
return sum(len(files) for _, _, files in os.walk(data_dir))
|
|
return 0
|
|
except Exception:
|
|
return 0
|
|
|
|
def _scripts_count_db(self) -> int:
|
|
try:
|
|
row = self.db.query_one("SELECT COUNT(*) AS c FROM scripts;")
|
|
return int(row["c"]) if row else 0
|
|
except Exception:
|
|
return 0
|
|
|
|
def _zombies_count_db(self) -> int:
|
|
try:
|
|
row = self.db.query_one("SELECT COUNT(*) AS c FROM stats WHERE id=1;")
|
|
if row and row.get("c") is not None:
|
|
return int(row["c"])
|
|
except Exception:
|
|
pass
|
|
try:
|
|
row = self.db.query_one("SELECT COUNT(*) AS c FROM agents WHERE LOWER(status)='online';")
|
|
return int(row["c"]) if row else 0
|
|
except Exception:
|
|
return 0
|
|
|
|
# ---------------------- Wi-Fi connus (profils NM) ----------------------
|
|
def _run_nmcli(self, args: list[str], timeout: float = 4.0) -> Optional[str]:
|
|
import shutil, os as _os
|
|
nmcli_path = shutil.which("nmcli") or "/usr/bin/nmcli"
|
|
env = _os.environ.copy()
|
|
env.setdefault("PATH", "/usr/sbin:/usr/bin:/sbin:/bin")
|
|
env.setdefault("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/run/dbus/system_bus_socket")
|
|
env.setdefault("LC_ALL", "C")
|
|
|
|
try:
|
|
out = subprocess.run(
|
|
[nmcli_path, *args],
|
|
capture_output=True, text=True, timeout=timeout, env=env
|
|
)
|
|
if out.returncode == 0:
|
|
stdout = out.stdout or ""
|
|
# self.logger.debug(f"nmcli ok args={args} -> {len(stdout.splitlines())} lines")
|
|
return stdout
|
|
# self.logger.debug(f"nmcli rc={out.returncode} args={args} stderr={(out.stderr or '').strip()}")
|
|
return None
|
|
except FileNotFoundError:
|
|
# self.logger.debug("nmcli introuvable")
|
|
return None
|
|
except Exception as e:
|
|
# self.logger.debug(f"nmcli exception {args}: {e}")
|
|
return None
|
|
|
|
def _known_wifi_count_nmcli(self) -> int:
|
|
# Try 1: simple (une valeur par ligne)
|
|
out = self._run_nmcli(["-t", "-g", "TYPE", "connection", "show"])
|
|
if out:
|
|
cnt = sum(1 for line in out.splitlines()
|
|
if (line or "").strip().lower() in ("wifi", "802-11-wireless"))
|
|
if cnt > 0:
|
|
# self.logger.debug(f"known wifi via nmcli TYPE = {cnt}")
|
|
return cnt
|
|
|
|
# Try 2: NAME,TYPE
|
|
out = self._run_nmcli(["-t", "-f", "NAME,TYPE", "connection", "show"])
|
|
if out:
|
|
cnt = 0
|
|
for line in out.splitlines():
|
|
line = (line or "").strip()
|
|
if not line:
|
|
continue
|
|
typ = line.rsplit(":", 1)[-1].strip().lower()
|
|
if typ in ("wifi", "802-11-wireless"):
|
|
cnt += 1
|
|
if cnt > 0:
|
|
# self.logger.debug(f"known wifi via nmcli NAME,TYPE = {cnt}")
|
|
return cnt
|
|
|
|
# Try 3: connection.type
|
|
out = self._run_nmcli(["-t", "-g", "connection.type", "connection", "show"])
|
|
if out:
|
|
cnt = sum(1 for line in out.splitlines()
|
|
if (line or "").strip().lower() in ("wifi", "802-11-wireless"))
|
|
if cnt > 0:
|
|
# self.logger.debug(f"known wifi via nmcli connection.type = {cnt}")
|
|
return cnt
|
|
|
|
# Fallback: wpa_supplicant.conf
|
|
try:
|
|
conf = self._read_text("/etc/wpa_supplicant/wpa_supplicant.conf") or ""
|
|
if conf:
|
|
blocks = conf.count("\nnetwork={")
|
|
if conf.strip().startswith("network={"):
|
|
blocks += 1
|
|
if blocks > 0:
|
|
# self.logger.debug(f"known wifi via wpa_supplicant.conf = {blocks}")
|
|
return blocks
|
|
except Exception:
|
|
pass
|
|
|
|
# Dernier recours: config persistée
|
|
v = self._cfg_get("wardrive_known", 0)
|
|
# self.logger.debug(f"known wifi via cfg fallback = {v}")
|
|
return int(v) if isinstance(v, (int, float)) else 0
|
|
|
|
# Cache wardrive: mémoire (par process) + DB (partagé multi-workers)
|
|
def _wardrive_known_cached(self) -> int:
|
|
now = time.time()
|
|
|
|
# 1) cache mémoire
|
|
if self._wardrive_cache_mem is not None and (now - self._wardrive_ts_mem) < self._wardrive_ttl:
|
|
return int(self._wardrive_cache_mem)
|
|
|
|
# 2) cache partagé en DB
|
|
try:
|
|
row = self.db.query_one("SELECT value FROM config WHERE key='wardrive_cache' LIMIT 1;")
|
|
if row and row.get("value"):
|
|
d = json.loads(row["value"])
|
|
ts = float(d.get("ts", 0))
|
|
if now - ts < self._wardrive_ttl:
|
|
val = int(d.get("val", 0))
|
|
self._wardrive_cache_mem = val
|
|
self._wardrive_ts_mem = now
|
|
return val
|
|
except Exception:
|
|
pass
|
|
|
|
# 3) refresh si nécessaire
|
|
val = int(self._known_wifi_count_nmcli())
|
|
|
|
# maj caches
|
|
self._wardrive_cache_mem = val
|
|
self._wardrive_ts_mem = now
|
|
self._cfg_set("wardrive_cache", {"val": val, "ts": now})
|
|
|
|
return val
|
|
|
|
# ---------------------- Accès direct shared_data ----------------------
|
|
def _count_open_ports_total(self) -> int:
|
|
try:
|
|
val = int(getattr(self.shared_data, "port_count", -1))
|
|
return val if val >= 0 else self._count_open_ports_total_db()
|
|
except Exception:
|
|
return self._count_open_ports_total_db()
|
|
|
|
def _alive_hosts(self) -> Tuple[int, int]:
|
|
try:
|
|
alive = int(getattr(self.shared_data, "target_count", -1))
|
|
total = int(getattr(self.shared_data, "network_kb_count", -1))
|
|
if alive >= 0 and total >= 0:
|
|
return alive, total
|
|
except Exception:
|
|
pass
|
|
return self._alive_hosts_db()
|
|
|
|
def _vulns_total(self) -> int:
|
|
try:
|
|
val = int(getattr(self.shared_data, "vuln_count", -1))
|
|
return val if val >= 0 else self._vulns_total_db()
|
|
except Exception:
|
|
return self._vulns_total_db()
|
|
|
|
def _credentials_count(self) -> int:
|
|
try:
|
|
val = int(getattr(self.shared_data, "cred_count", -1))
|
|
return val if val >= 0 else self._credentials_count_db()
|
|
except Exception:
|
|
return self._credentials_count_db()
|
|
|
|
def _files_count(self) -> int:
|
|
try:
|
|
val = int(getattr(self.shared_data, "data_count", -1))
|
|
return val if val >= 0 else self._files_count_fs()
|
|
except Exception:
|
|
return self._files_count_fs()
|
|
|
|
def _scripts_count(self) -> int:
|
|
try:
|
|
val = int(getattr(self.shared_data, "attacks_count", -1))
|
|
return val if val >= 0 else self._scripts_count_db()
|
|
except Exception:
|
|
return self._scripts_count_db()
|
|
|
|
def _zombies_count(self) -> int:
|
|
try:
|
|
val = int(getattr(self.shared_data, "zombie_count", -1))
|
|
return val if val >= 0 else self._zombies_count_db()
|
|
except Exception:
|
|
return self._zombies_count_db()
|
|
|
|
def level_bjorn(self) -> int:
|
|
try:
|
|
val = int(getattr(self.shared_data, "level_count", -1))
|
|
return val if val >= 0 else int(self._cfg_get("level_count", 1))
|
|
except Exception:
|
|
return int(self._cfg_get("level_count", 1))
|
|
|
|
def _mode_str(self) -> str:
|
|
try:
|
|
manual = bool(getattr(self.shared_data, "manual_mode", False))
|
|
return "MANUAL" if manual else "AUTO"
|
|
except Exception:
|
|
return str(self._cfg_get("bjorn_mode", "AUTO")).upper()
|
|
|
|
# ---------------------- Delta vuln depuis dernier scan ----------------------
|
|
def _vulns_delta(self) -> int:
|
|
last_scan_ts = self._cfg_get("vuln_last_scan_ts")
|
|
if not last_scan_ts:
|
|
return 0
|
|
try:
|
|
row = self.db.query_one(
|
|
"""
|
|
SELECT COUNT(*) AS c
|
|
FROM vulnerability_history
|
|
WHERE event='new'
|
|
AND CAST(strftime('%s', seen_at) AS INTEGER) >= ?
|
|
""",
|
|
(int(last_scan_ts),),
|
|
)
|
|
new_count = int(row["c"]) if row else 0
|
|
row = self.db.query_one(
|
|
"""
|
|
SELECT COUNT(*) AS c
|
|
FROM vulnerability_history
|
|
WHERE event='inactive'
|
|
AND CAST(strftime('%s', seen_at) AS INTEGER) >= ?
|
|
""",
|
|
(int(last_scan_ts),),
|
|
)
|
|
removed_count = int(row["c"]) if row else 0
|
|
return new_count - removed_count
|
|
except Exception:
|
|
return 0
|
|
|
|
# ---------------------- Assemblage principal ----------------------
|
|
def _assemble_stats(self) -> Dict[str, Any]:
|
|
now = time.time()
|
|
if self._last_stats and (now - self._last_stats_ts) < self._cache_ttl:
|
|
return self._last_stats
|
|
|
|
try:
|
|
# Comptages rapides via shared_data (+ fallback DB)
|
|
alive, total = self._alive_hosts()
|
|
open_ports_total = self._count_open_ports_total()
|
|
vulns_total = self._vulns_total()
|
|
vulns_delta = self._vulns_delta()
|
|
creds = self._credentials_count()
|
|
zombies = self._zombies_count()
|
|
files_count = self._files_count()
|
|
scripts_count = self._scripts_count()
|
|
wardrive = self._wardrive_known_cached()
|
|
|
|
# Système
|
|
sys_info = self._get_system_info()
|
|
uptime = self._uptime_str()
|
|
first_init = self._first_init_ts()
|
|
|
|
# Meta Bjorn
|
|
bjorn_level = self.level_bjorn()
|
|
|
|
# Ressources
|
|
cpu_pct = self._cpu_pct()
|
|
ram_used, ram_total = self._mem_bytes()
|
|
sto_used, sto_total = self._disk_bytes()
|
|
|
|
# Batterie
|
|
batt = self._battery_probe()
|
|
|
|
# Réseau
|
|
internet_ok = self._quick_internet()
|
|
gw, dns = self._gw_dns()
|
|
wifi_ip = self._ip_for("wlan0")
|
|
wifi_ssid = self._wifi_ssid() if wifi_ip else None
|
|
eth_ip = self._ip_for("eth0")
|
|
wifi_radio = self._wifi_radio_on()
|
|
bt_radio = self._bt_radio_on()
|
|
eth_link = self._eth_link_up("eth0")
|
|
usb_phys = self._usb_gadget_active()
|
|
|
|
# USB/BT gadgets
|
|
usb_lease = self._detect_gadget_lease("172.20.1.")
|
|
bt_lease = self._detect_gadget_lease("172.20.2.")
|
|
bt_device = self._bt_connected_device() if bt_lease else None
|
|
|
|
# GPS
|
|
gps_data = self._gps_status()
|
|
|
|
# Mode
|
|
mode = self._mode_str()
|
|
|
|
# FDs
|
|
fds_count = self._open_fds_count()
|
|
fds_used, fds_limit = self._fds_usage()
|
|
|
|
stats = {
|
|
"timestamp": int(time.time()),
|
|
"first_init_ts": int(first_init),
|
|
"mode": mode,
|
|
"uptime": uptime,
|
|
"bjorn_level": bjorn_level,
|
|
"internet_access": bool(internet_ok),
|
|
|
|
# Hôtes & ports
|
|
"known_hosts_total": int(total),
|
|
"alive_hosts": int(alive),
|
|
"open_ports_alive_total": int(open_ports_total),
|
|
|
|
# Comptes sécurité
|
|
"wardrive_known": int(wardrive),
|
|
"vulnerabilities": int(vulns_total),
|
|
"vulns_delta": int(vulns_delta),
|
|
"attack_scripts": int(scripts_count),
|
|
"zombies": int(zombies),
|
|
"credentials": int(creds),
|
|
"files_found": int(files_count),
|
|
|
|
"system": {
|
|
"os_name": sys_info["os_name"],
|
|
"os_version": sys_info["os_version"],
|
|
"arch": sys_info["arch"],
|
|
"model": sys_info["model"],
|
|
"waveshare_epd_connected": sys_info["waveshare_epd_connected"],
|
|
"waveshare_epd_type": sys_info["waveshare_epd_type"],
|
|
"cpu_pct": int(cpu_pct),
|
|
"ram_used_bytes": int(ram_used),
|
|
"ram_total_bytes": int(ram_total),
|
|
"storage_used_bytes": int(sto_used),
|
|
"storage_total_bytes": int(sto_total),
|
|
"open_fds": int(fds_used),
|
|
"fds_limit": int(fds_limit),
|
|
"fds_global": int(fds_count),
|
|
|
|
},
|
|
|
|
"gps": {**gps_data},
|
|
"battery": {**batt},
|
|
|
|
"connectivity": {
|
|
"wifi": bool(wifi_ip),
|
|
"wifi_radio_on": bool(wifi_radio), # <--- NEW
|
|
"wifi_ssid": wifi_ssid,
|
|
"wifi_ip": wifi_ip,
|
|
"wifi_gw": gw if wifi_ip else None,
|
|
"wifi_dns": dns if wifi_ip else None,
|
|
|
|
"ethernet": bool(eth_ip),
|
|
"eth_link_up": bool(eth_link), # <--- NEW
|
|
"eth_ip": eth_ip,
|
|
"eth_gw": gw if eth_ip else None,
|
|
"eth_dns": dns if eth_ip else None,
|
|
|
|
"usb_gadget": bool(usb_lease),
|
|
"usb_phys_on": bool(usb_phys), # <--- NEW (radio/phys)
|
|
"usb_lease_ip": usb_lease,
|
|
"usb_mode": self._get_usb_mode(),
|
|
|
|
"bt_gadget": bool(bt_lease),
|
|
"bt_radio_on": bool(bt_radio), # <--- NEW
|
|
"bt_lease_ip": bt_lease,
|
|
"bt_connected_to": bt_device,
|
|
},
|
|
|
|
}
|
|
|
|
self._last_stats = stats
|
|
self._last_stats_ts = now
|
|
return stats
|
|
|
|
except Exception as e:
|
|
if hasattr(self.logger, "error"):
|
|
self.logger.error(f"Error assembling stats: {e}")
|
|
if self._last_stats:
|
|
return self._last_stats
|
|
return self._get_fallback_stats()
|
|
|
|
def _get_usb_mode(self) -> str:
|
|
try:
|
|
udc = self._read_text("/sys/kernel/config/usb_gadget/g1/UDC")
|
|
if udc:
|
|
return "OTG"
|
|
except Exception:
|
|
pass
|
|
return "Device"
|
|
|
|
def _get_fallback_stats(self) -> Dict[str, Any]:
|
|
return {
|
|
"timestamp": int(time.time()),
|
|
"status": "error",
|
|
"message": "Stats collection error - using fallback",
|
|
"alive_hosts": 0,
|
|
"known_hosts_total": 0,
|
|
"open_ports_alive_total": 0,
|
|
"vulnerabilities": 0,
|
|
"internet_access": False,
|
|
"system": {
|
|
"os_name": "Unknown",
|
|
"cpu_pct": 0,
|
|
"ram_used_bytes": 0,
|
|
"ram_total_bytes": 0,
|
|
"storage_used_bytes": 0,
|
|
"storage_total_bytes": 0,
|
|
},
|
|
"connectivity": {"wifi": False, "ethernet": False, "usb_gadget": False, "bt_gadget": False},
|
|
"gps": {"connected": False},
|
|
"battery": {"present": False},
|
|
}
|
|
|
|
# ---------------------- REST ----------------------
|
|
def dashboard_stats(self, handler):
|
|
try:
|
|
stats = self._assemble_stats()
|
|
return self._json(handler, 200, stats)
|
|
except Exception as e:
|
|
if hasattr(self.logger, "error"):
|
|
self.logger.error(f"/api/bjorn/stats error: {e}")
|
|
self.logger.error("Serving cached stats after error")
|
|
if self._last_stats:
|
|
return self._json(handler, 200, self._last_stats)
|
|
return self._json(
|
|
handler,
|
|
500,
|
|
{"status": "error", "message": str(e), "fallback": self._get_fallback_stats()},
|
|
)
|
|
|
|
def set_config(self, handler, data: Dict[str, Any]):
|
|
key = (data.get("key") or "").strip()
|
|
if not key:
|
|
return self._json(handler, 400, {"status": "error", "message": "key required"})
|
|
try:
|
|
self._cfg_set(key, data.get("value"))
|
|
if key in ["epd_type", "bjorn_mode", "gps_enabled"]:
|
|
self._system_info_cache = {}
|
|
self._last_stats = {}
|
|
return self._json(handler, 200, {"status": "ok", "key": key})
|
|
except Exception as e:
|
|
return self._json(handler, 400, {"status": "error", "message": str(e)})
|
|
|
|
def mark_vuln_scan_baseline(self, handler):
|
|
now = int(time.time())
|
|
self._cfg_set("vuln_last_scan_ts", now)
|
|
return self._json(handler, 200, {"status": "ok", "vuln_last_scan_ts": now})
|
|
|
|
|
|
|
|
def reload_generate_actions_json(self, handler):
|
|
"""Recharge le fichier actions.json en exécutant generate_actions_json."""
|
|
try:
|
|
self.shared_data.generate_actions_json()
|
|
handler.send_response(200)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({'status': 'success', 'message': 'actions.json reloaded successfully.'}).encode('utf-8'))
|
|
except Exception as e:
|
|
self.logger.error(f"Error in reload_generate_actions_json: {e}")
|
|
handler.send_response(500)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({'status': 'error', 'message': str(e)}).encode('utf-8'))
|
|
|
|
|
|
def clear_shared_config_json(self, handler, restart=True):
|
|
"""Reset config à partir des defaults, en DB."""
|
|
try:
|
|
self.shared_data.config = self.shared_data.get_default_config()
|
|
self.shared_data.save_config() # -> DB
|
|
if restart:
|
|
self.restart_bjorn_service(handler)
|
|
handler.send_response(200)
|
|
handler.send_header("Content-type","application/json")
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({"status":"success","message":"Configuration reset to defaults"}).encode("utf-8"))
|
|
except Exception as e:
|
|
handler.send_response(500)
|
|
handler.send_header("Content-type","application/json")
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({"status":"error","message":str(e)}).encode("utf-8"))
|
|
|
|
|
|
|
|
def serve_favicon(self, handler):
|
|
handler.send_response(200)
|
|
handler.send_header("Content-type", "image/x-icon")
|
|
handler.end_headers()
|
|
favicon_path = os.path.join(self.shared_data.web_dir, '/images/favicon.ico')
|
|
self.logger.info(f"Serving favicon from {favicon_path}")
|
|
try:
|
|
with open(favicon_path, 'rb') as file:
|
|
handler.wfile.write(file.read())
|
|
except FileNotFoundError:
|
|
self.logger.error(f"Favicon not found at {favicon_path}")
|
|
handler.send_response(404)
|
|
handler.end_headers()
|
|
|
|
def serve_manifest(self, handler):
|
|
handler.send_response(200)
|
|
handler.send_header("Content-type", "application/json")
|
|
handler.end_headers()
|
|
manifest_path = os.path.join(self.shared_data.web_dir, 'manifest.json')
|
|
try:
|
|
with open(manifest_path, 'r') as file:
|
|
handler.wfile.write(file.read().encode('utf-8'))
|
|
except FileNotFoundError:
|
|
handler.send_response(404)
|
|
handler.end_headers()
|
|
|
|
def serve_apple_touch_icon(self, handler):
|
|
handler.send_response(200)
|
|
handler.send_header("Content-type", "image/png")
|
|
handler.end_headers()
|
|
icon_path = os.path.join(self.shared_data.web_dir, 'icons/apple-touch-icon.png')
|
|
try:
|
|
with open(icon_path, 'rb') as file:
|
|
handler.wfile.write(file.read())
|
|
except FileNotFoundError:
|
|
handler.send_response(404)
|
|
handler.end_headers()
|
|
|
|
|
|
|
|
# --- Nouveaux probes "radio / link" ---
|
|
def _wifi_radio_on(self) -> bool:
|
|
# nmcli (NetworkManager)
|
|
try:
|
|
out = subprocess.run(["nmcli", "radio", "wifi"], capture_output=True, text=True, timeout=1)
|
|
if out.returncode == 0:
|
|
return out.stdout.strip().lower().startswith("enabled")
|
|
except Exception:
|
|
pass
|
|
# rfkill (fallback)
|
|
try:
|
|
out = subprocess.run(["rfkill", "list"], capture_output=True, text=True, timeout=1)
|
|
if out.returncode == 0:
|
|
block = out.stdout.lower()
|
|
if "wireless" in block or "wlan" in block or "wifi" in block:
|
|
if "soft blocked: yes" in block or "hard blocked: yes" in block:
|
|
return False
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
def _bt_radio_on(self) -> bool:
|
|
import shutil, os as _os
|
|
btctl = shutil.which("bluetoothctl") or "/usr/bin/bluetoothctl"
|
|
env = _os.environ.copy()
|
|
env.setdefault("PATH", "/usr/sbin:/usr/bin:/sbin:/bin")
|
|
# important quand on tourne en service systemd
|
|
env.setdefault("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/run/dbus/system_bus_socket")
|
|
|
|
try:
|
|
out = subprocess.run([btctl, "show"], capture_output=True, text=True, timeout=1.2, env=env)
|
|
if out.returncode == 0:
|
|
txt = (out.stdout or "").lower()
|
|
if "no default controller available" in txt:
|
|
# Essayer de lister et cibler le premier contrôleur
|
|
ls = subprocess.run([btctl, "list"], capture_output=True, text=True, timeout=1.2, env=env)
|
|
if ls.returncode == 0:
|
|
for line in (ls.stdout or "").splitlines():
|
|
# ex: "Controller AA:BB:CC:DD:EE:FF host [default]"
|
|
if "controller " in line.lower():
|
|
mac = line.split()[1]
|
|
sh = subprocess.run([btctl, "-a", mac, "show"], capture_output=True, text=True, timeout=1.2, env=env)
|
|
if sh.returncode == 0 and "powered: yes" in (sh.stdout or "").lower():
|
|
return True
|
|
return False
|
|
# cas normal
|
|
if "powered: yes" in txt:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback rfkill
|
|
try:
|
|
out = subprocess.run(["rfkill", "list"], capture_output=True, text=True, timeout=1.0, env=env)
|
|
if out.returncode == 0:
|
|
block = (out.stdout or "").lower()
|
|
if "bluetooth" in block:
|
|
if "soft blocked: yes" in block or "hard blocked: yes" in block:
|
|
return False
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _eth_link_up(self, ifname: str = "eth0") -> bool:
|
|
# ip link show eth0
|
|
try:
|
|
out = subprocess.run(["ip", "link", "show", ifname], capture_output=True, text=True, timeout=1)
|
|
if out.returncode == 0:
|
|
# "state UP" ou "LOWER_UP"
|
|
t = out.stdout.upper()
|
|
return ("STATE UP" in t) or ("LOWER_UP" in t)
|
|
except Exception:
|
|
pass
|
|
# ethtool (si dispo)
|
|
try:
|
|
out = subprocess.run(["ethtool", ifname], capture_output=True, text=True, timeout=1)
|
|
if out.returncode == 0:
|
|
for line in out.stdout.splitlines():
|
|
if line.strip().lower().startswith("link detected:"):
|
|
return line.split(":",1)[1].strip().lower() == "yes"
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
def _usb_gadget_active(self) -> bool:
|
|
# actif si un UDC est attaché
|
|
try:
|
|
udc = self._read_text("/sys/kernel/config/usb_gadget/g1/UDC")
|
|
return bool(udc and udc.strip())
|
|
except Exception:
|
|
return False
|