Add Phase 1 utility modules: crypto, networking, logging, stealth, resource, permissions, config_loader, bettercap_api
9 files in utils/ providing the shared infrastructure layer: - crypto: AES-256-GCM file encryption, argon2id/PBKDF2 key derivation, HKDF network unlock, LUKS container management - networking: interface detection, MAC/IP helpers, BPF compilation via libpcap ctypes, gratuitous ARP, VLAN creation - logging: encrypted log writer (BBLogger) with rotation, per-module files, stdout suppression for stealth - stealth: process rename via prctl, cmdline spoofing, sysctl helpers, timestomping, core dump disable - resource: hardware tier detection (OPi Zero 3 / Pi Zero / generic) via /proc/device-tree and cpuinfo, resource monitoring - permissions: root/capability checks via capget ctypes, privilege dropping, directory permission enforcement - config_loader: YAML merge hierarchy (hardware_tiers -> bigbrother -> modules -> stealth -> CLI), tier auto-detection, SIGHUP reload - bettercap_api: REST client with per-session random credentials, session/events/command methods
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
from utils.crypto import encrypt_file, decrypt_file, derive_key, CryptoEngine
|
||||
from utils.networking import (
|
||||
get_primary_interface, get_bridge_candidates, get_wifi_interfaces,
|
||||
get_mac, set_mac, get_ip, compile_bpf, send_gratuitous_arp,
|
||||
interface_up, interface_down, create_vlan_interface,
|
||||
)
|
||||
from utils.logging import BBLogger
|
||||
from utils.stealth import (
|
||||
rename_process, spoof_cmdline, set_sysctl, timestomp, disable_core_dumps,
|
||||
)
|
||||
from utils.resource import HardwareInfo, get_hardware_tier, get_resource_usage
|
||||
from utils.permissions import (
|
||||
check_root, check_capability, drop_privileges, enforce_directory_permissions,
|
||||
)
|
||||
from utils.config_loader import load_config, ConfigLoader
|
||||
from utils.bettercap_api import BettercapAPI
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""REST client for bettercap API."""
|
||||
|
||||
import secrets
|
||||
import string
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
|
||||
class BettercapAPI:
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8083,
|
||||
user: str = "bb",
|
||||
password: Optional[str] = None,
|
||||
timeout: int = 10,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.user = user
|
||||
self.password = password or self._generate_password()
|
||||
self.timeout = timeout
|
||||
self._base_url = f"http://{host}:{port}/api"
|
||||
self._auth = HTTPBasicAuth(self.user, self.password)
|
||||
self._session = requests.Session()
|
||||
self._session.auth = self._auth
|
||||
self._session.headers.update({"Content-Type": "application/json"})
|
||||
|
||||
@staticmethod
|
||||
def _generate_password(length: int = 32) -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
||||
url = f"{self._base_url}/{endpoint.lstrip('/')}"
|
||||
kwargs.setdefault("timeout", self.timeout)
|
||||
try:
|
||||
resp = self._session.request(method, url, **kwargs)
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise ConnectionError(f"Cannot connect to bettercap at {self.host}:{self.port}")
|
||||
except requests.exceptions.Timeout:
|
||||
raise TimeoutError(f"Request to bettercap timed out after {self.timeout}s")
|
||||
|
||||
def run(self, command: str) -> Dict[str, Any]:
|
||||
resp = self._request("POST", "session", json={"cmd": command})
|
||||
return resp.json()
|
||||
|
||||
def get_session(self) -> Dict[str, Any]:
|
||||
resp = self._request("GET", "session")
|
||||
return resp.json()
|
||||
|
||||
def get_events(self, since: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
params = {}
|
||||
if since is not None:
|
||||
params["n"] = since
|
||||
resp = self._request("GET", "events", params=params)
|
||||
return resp.json()
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
try:
|
||||
self.get_session()
|
||||
return True
|
||||
except (ConnectionError, TimeoutError, requests.exceptions.RequestException):
|
||||
return False
|
||||
|
||||
def start_module(self, module: str) -> Dict[str, Any]:
|
||||
return self.run(f"{module} on")
|
||||
|
||||
def stop_module(self, module: str) -> Dict[str, Any]:
|
||||
return self.run(f"{module} off")
|
||||
|
||||
def set_parameter(self, param: str, value: str) -> Dict[str, Any]:
|
||||
return self.run(f"set {param} {value}")
|
||||
|
||||
def get_hosts(self) -> List[Dict[str, Any]]:
|
||||
session = self.get_session()
|
||||
return session.get("lan", {}).get("hosts", [])
|
||||
|
||||
def load_caplet(self, caplet_path: str) -> Dict[str, Any]:
|
||||
return self.run(f"caplet.load {caplet_path}")
|
||||
|
||||
@property
|
||||
def credentials(self) -> Dict[str, str]:
|
||||
return {"user": self.user, "password": self.password}
|
||||
|
||||
@property
|
||||
def api_flags(self) -> List[str]:
|
||||
return [
|
||||
"-api-rest-address", self.host,
|
||||
"-api-rest-port", str(self.port),
|
||||
"-api-rest-username", self.user,
|
||||
"-api-rest-password", self.password,
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""YAML config loader with merge hierarchy, hardware tier detection, and SIGHUP reload."""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from utils.resource import get_hardware_tier
|
||||
|
||||
CONFIG_DIR = "config"
|
||||
MERGE_ORDER = [
|
||||
"hardware_tiers.yaml",
|
||||
"bigbrother.yaml",
|
||||
"modules.yaml",
|
||||
"stealth.yaml",
|
||||
]
|
||||
|
||||
|
||||
def deep_merge(base: dict, override: dict) -> dict:
|
||||
result = copy.deepcopy(base)
|
||||
for key, value in override.items():
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
result[key] = deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = copy.deepcopy(value)
|
||||
return result
|
||||
|
||||
|
||||
class ConfigLoader:
|
||||
def __init__(
|
||||
self,
|
||||
config_dir: str = CONFIG_DIR,
|
||||
cli_overrides: Optional[Dict[str, Any]] = None,
|
||||
auto_detect_tier: bool = True,
|
||||
):
|
||||
self.config_dir = Path(config_dir)
|
||||
self.cli_overrides = cli_overrides or {}
|
||||
self.auto_detect_tier = auto_detect_tier
|
||||
self._config: Dict[str, Any] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._reload_callbacks: List[Callable] = []
|
||||
self._loaded = False
|
||||
|
||||
def load(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
self._config = self._build_config()
|
||||
self._loaded = True
|
||||
return copy.deepcopy(self._config)
|
||||
|
||||
def _build_config(self) -> Dict[str, Any]:
|
||||
merged: Dict[str, Any] = {}
|
||||
|
||||
for filename in MERGE_ORDER:
|
||||
filepath = self.config_dir / filename
|
||||
if filepath.exists():
|
||||
try:
|
||||
with open(filepath, "r") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
merged = deep_merge(merged, data)
|
||||
except (yaml.YAMLError, IOError):
|
||||
continue
|
||||
|
||||
# Apply hardware tier defaults
|
||||
if self.auto_detect_tier:
|
||||
merged = self._apply_tier_limits(merged)
|
||||
|
||||
# Apply CLI overrides last (highest priority)
|
||||
if self.cli_overrides:
|
||||
merged = deep_merge(merged, self.cli_overrides)
|
||||
|
||||
# Validate
|
||||
self._validate(merged)
|
||||
|
||||
return merged
|
||||
|
||||
def _apply_tier_limits(self, config: dict) -> dict:
|
||||
tier = config.get("device", {}).get("platform", "auto")
|
||||
if tier == "auto":
|
||||
tier = get_hardware_tier()
|
||||
config.setdefault("device", {})["platform"] = tier
|
||||
|
||||
# Extract tier-specific limits from hardware_tiers section
|
||||
tiers = config.pop("hardware_tiers", {})
|
||||
tier_config = tiers.get(tier, tiers.get("generic", {}))
|
||||
|
||||
if tier_config:
|
||||
config = deep_merge(config, tier_config)
|
||||
|
||||
# Apply well-known tier constraints
|
||||
if tier == "pi_zero":
|
||||
config.setdefault("capture", {})["pcap_compression_level"] = 3
|
||||
config.setdefault("security", {})["encryption_key_derive"] = "pbkdf2"
|
||||
# Limit passive modules
|
||||
limits = config.setdefault("limits", {})
|
||||
limits.setdefault("max_passive_modules", 4)
|
||||
limits.setdefault("max_active_modules", 0)
|
||||
elif tier == "opi_zero3":
|
||||
config.setdefault("capture", {})["pcap_compression_level"] = 19
|
||||
config.setdefault("security", {})["encryption_key_derive"] = "argon2id"
|
||||
limits = config.setdefault("limits", {})
|
||||
limits.setdefault("max_passive_modules", 16)
|
||||
limits.setdefault("max_active_modules", 6)
|
||||
else:
|
||||
config.setdefault("capture", {})["pcap_compression_level"] = 19
|
||||
config.setdefault("security", {})["encryption_key_derive"] = "argon2id"
|
||||
limits = config.setdefault("limits", {})
|
||||
limits.setdefault("max_passive_modules", 16)
|
||||
limits.setdefault("max_active_modules", -1) # unlimited
|
||||
|
||||
return config
|
||||
|
||||
def _validate(self, config: dict) -> None:
|
||||
device = config.get("device", {})
|
||||
platform = device.get("platform", "")
|
||||
if platform and platform not in ("auto", "opi_zero3", "pi_zero", "generic"):
|
||||
raise ValueError(f"Invalid platform: {platform}")
|
||||
|
||||
capture = config.get("capture", {})
|
||||
comp_level = capture.get("pcap_compression_level")
|
||||
if comp_level is not None and not (1 <= comp_level <= 22):
|
||||
raise ValueError(f"Invalid compression level: {comp_level}")
|
||||
|
||||
bettercap = config.get("bettercap", {})
|
||||
port = bettercap.get("api_port")
|
||||
if port is not None and not (1 <= port <= 65535):
|
||||
raise ValueError(f"Invalid bettercap API port: {port}")
|
||||
|
||||
security = config.get("security", {})
|
||||
kdf = security.get("encryption_key_derive")
|
||||
if kdf and kdf not in ("argon2id", "pbkdf2"):
|
||||
raise ValueError(f"Invalid key derivation method: {kdf}")
|
||||
|
||||
def get(self, key_path: str, default: Any = None) -> Any:
|
||||
with self._lock:
|
||||
keys = key_path.split(".")
|
||||
current = self._config
|
||||
for k in keys:
|
||||
if isinstance(current, dict) and k in current:
|
||||
current = current[k]
|
||||
else:
|
||||
return default
|
||||
return copy.deepcopy(current)
|
||||
|
||||
def reload(self) -> Dict[str, Any]:
|
||||
config = self.load()
|
||||
for cb in self._reload_callbacks:
|
||||
try:
|
||||
cb(config)
|
||||
except Exception:
|
||||
pass
|
||||
return config
|
||||
|
||||
def on_reload(self, callback: Callable):
|
||||
self._reload_callbacks.append(callback)
|
||||
|
||||
def register_sighup(self):
|
||||
def _handler(signum, frame):
|
||||
self.reload()
|
||||
signal.signal(signal.SIGHUP, _handler)
|
||||
|
||||
@property
|
||||
def config(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
if not self._loaded:
|
||||
return self.load()
|
||||
return copy.deepcopy(self._config)
|
||||
|
||||
|
||||
def load_config(
|
||||
config_dir: str = CONFIG_DIR,
|
||||
cli_overrides: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
loader = ConfigLoader(config_dir=config_dir, cli_overrides=cli_overrides)
|
||||
return loader.load()
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AES-256-GCM encryption, key derivation, and LUKS container management."""
|
||||
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
|
||||
try:
|
||||
from argon2.low_level import hash_secret_raw, Type
|
||||
HAS_ARGON2 = True
|
||||
except ImportError:
|
||||
HAS_ARGON2 = False
|
||||
|
||||
NONCE_SIZE = 12
|
||||
KEY_SIZE = 32
|
||||
SALT_SIZE = 16
|
||||
HEADER_MAGIC = b"BB01"
|
||||
HEADER_FMT = f"!4s{SALT_SIZE}s{NONCE_SIZE}s"
|
||||
HEADER_SIZE = struct.calcsize(HEADER_FMT)
|
||||
|
||||
|
||||
def derive_key(password: bytes, salt: bytes, method: str = "argon2id") -> bytes:
|
||||
if method == "argon2id" and HAS_ARGON2:
|
||||
return hash_secret_raw(
|
||||
secret=password,
|
||||
salt=salt,
|
||||
time_cost=3,
|
||||
memory_cost=65536,
|
||||
parallelism=4,
|
||||
hash_len=KEY_SIZE,
|
||||
type=Type.ID,
|
||||
)
|
||||
return PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=KEY_SIZE,
|
||||
salt=salt,
|
||||
iterations=600_000,
|
||||
).derive(password)
|
||||
|
||||
|
||||
def derive_network_key(network_secret: bytes, device_serial: bytes) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=KEY_SIZE,
|
||||
salt=device_serial,
|
||||
info=b"bigbrother-luks-unlock",
|
||||
).derive(network_secret)
|
||||
|
||||
|
||||
class CryptoEngine:
|
||||
def __init__(self, key: bytes):
|
||||
if len(key) != KEY_SIZE:
|
||||
raise ValueError(f"Key must be {KEY_SIZE} bytes")
|
||||
self._aesgcm = AESGCM(key)
|
||||
self._key = key
|
||||
|
||||
def encrypt(self, plaintext: bytes, aad: Optional[bytes] = None) -> bytes:
|
||||
nonce = os.urandom(NONCE_SIZE)
|
||||
ct = self._aesgcm.encrypt(nonce, plaintext, aad)
|
||||
return nonce + ct
|
||||
|
||||
def decrypt(self, data: bytes, aad: Optional[bytes] = None) -> bytes:
|
||||
nonce = data[:NONCE_SIZE]
|
||||
ct = data[NONCE_SIZE:]
|
||||
return self._aesgcm.decrypt(nonce, ct, aad)
|
||||
|
||||
|
||||
def encrypt_file(
|
||||
src: str,
|
||||
dst: str,
|
||||
password: bytes,
|
||||
method: str = "argon2id",
|
||||
chunk_size: int = 64 * 1024,
|
||||
) -> None:
|
||||
salt = os.urandom(SALT_SIZE)
|
||||
key = derive_key(password, salt, method)
|
||||
engine = CryptoEngine(key)
|
||||
|
||||
with open(src, "rb") as fin:
|
||||
plaintext = fin.read()
|
||||
|
||||
nonce = os.urandom(NONCE_SIZE)
|
||||
aesgcm = AESGCM(key)
|
||||
ct = aesgcm.encrypt(nonce, plaintext, None)
|
||||
|
||||
with open(dst, "wb") as fout:
|
||||
fout.write(struct.pack(HEADER_FMT, HEADER_MAGIC, salt, nonce))
|
||||
fout.write(ct)
|
||||
|
||||
|
||||
def decrypt_file(
|
||||
src: str,
|
||||
dst: str,
|
||||
password: bytes,
|
||||
method: str = "argon2id",
|
||||
) -> None:
|
||||
with open(src, "rb") as fin:
|
||||
header = fin.read(HEADER_SIZE)
|
||||
magic, salt, nonce = struct.unpack(HEADER_FMT, header)
|
||||
if magic != HEADER_MAGIC:
|
||||
raise ValueError("Invalid encrypted file header")
|
||||
ct = fin.read()
|
||||
|
||||
key = derive_key(password, salt, method)
|
||||
aesgcm = AESGCM(key)
|
||||
plaintext = aesgcm.decrypt(nonce, ct, None)
|
||||
|
||||
with open(dst, "wb") as fout:
|
||||
fout.write(plaintext)
|
||||
|
||||
|
||||
class LUKSContainer:
|
||||
def __init__(self, container_path: str, mount_point: str, mapper_name: str = "bb_storage"):
|
||||
self.container_path = container_path
|
||||
self.mount_point = mount_point
|
||||
self.mapper_name = mapper_name
|
||||
self.device_path = f"/dev/mapper/{mapper_name}"
|
||||
|
||||
def create(self, size_mb: int, passphrase: bytes) -> bool:
|
||||
try:
|
||||
subprocess.run(
|
||||
["dd", "if=/dev/zero", f"of={self.container_path}",
|
||||
"bs=1M", f"count={size_mb}"],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["cryptsetup", "luksFormat", "--type", "luks2",
|
||||
"--cipher", "aes-xts-plain64", "--key-size", "512",
|
||||
"--hash", "sha256", "--batch-mode", self.container_path],
|
||||
input=passphrase + b"\n",
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def open(self, passphrase: bytes) -> bool:
|
||||
try:
|
||||
subprocess.run(
|
||||
["cryptsetup", "open", self.container_path,
|
||||
self.mapper_name, "--type", "luks2"],
|
||||
input=passphrase + b"\n",
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
Path(self.mount_point).mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
["mount", self.device_path, self.mount_point],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def close(self) -> bool:
|
||||
try:
|
||||
subprocess.run(
|
||||
["umount", self.mount_point],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["cryptsetup", "close", self.mapper_name],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def destroy_header(self) -> bool:
|
||||
try:
|
||||
subprocess.run(
|
||||
["dd", "if=/dev/urandom", f"of={self.container_path}",
|
||||
"bs=1M", "count=2", "conv=notrunc"],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return Path(self.device_path).exists()
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Encrypted log writer for BigBrother. Named bb_logging to avoid shadowing stdlib logging."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
LOG_FORMAT = "[{timestamp}] [{module}] [{level}] {message}"
|
||||
MAX_LOG_SIZE = 10 * 1024 * 1024 # 10MB default rotation size
|
||||
LOG_DIR = "storage/logs"
|
||||
|
||||
|
||||
class EncryptedLogHandler(logging.Handler):
|
||||
def __init__(
|
||||
self,
|
||||
module_name: str,
|
||||
log_dir: str = LOG_DIR,
|
||||
max_bytes: int = MAX_LOG_SIZE,
|
||||
encrypt: bool = False,
|
||||
crypto_engine=None,
|
||||
):
|
||||
super().__init__()
|
||||
self.module_name = module_name
|
||||
self.log_dir = Path(log_dir)
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.max_bytes = max_bytes
|
||||
self.encrypt = encrypt
|
||||
self.crypto_engine = crypto_engine
|
||||
self._log_path = self.log_dir / f"{module_name}.log"
|
||||
self._file = None
|
||||
self._current_size = 0
|
||||
self._open_file()
|
||||
|
||||
def _open_file(self):
|
||||
if self._file:
|
||||
self._file.close()
|
||||
mode = "ab" if self.encrypt else "a"
|
||||
self._file = open(self._log_path, mode)
|
||||
self._current_size = self._log_path.stat().st_size if self._log_path.exists() else 0
|
||||
|
||||
def _rotate(self):
|
||||
if self._file:
|
||||
self._file.close()
|
||||
rotated = self._log_path.with_suffix(f".log.{int(time.time())}")
|
||||
if self._log_path.exists():
|
||||
self._log_path.rename(rotated)
|
||||
self._open_file()
|
||||
|
||||
def emit(self, record):
|
||||
try:
|
||||
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(record.created))
|
||||
line = LOG_FORMAT.format(
|
||||
timestamp=ts,
|
||||
module=self.module_name,
|
||||
level=record.levelname,
|
||||
message=record.getMessage(),
|
||||
)
|
||||
if self.encrypt and self.crypto_engine:
|
||||
data = self.crypto_engine.encrypt(line.encode("utf-8"))
|
||||
length_prefix = len(data).to_bytes(4, "big")
|
||||
self._file.write(length_prefix)
|
||||
self._file.write(data)
|
||||
self._current_size += 4 + len(data)
|
||||
else:
|
||||
line += "\n"
|
||||
self._file.write(line)
|
||||
self._current_size += len(line.encode("utf-8"))
|
||||
|
||||
self._file.flush()
|
||||
|
||||
if self._current_size >= self.max_bytes:
|
||||
self._rotate()
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
def close(self):
|
||||
if self._file:
|
||||
self._file.close()
|
||||
self._file = None
|
||||
super().close()
|
||||
|
||||
|
||||
class BBLogger:
|
||||
def __init__(
|
||||
self,
|
||||
module_name: str,
|
||||
log_dir: str = LOG_DIR,
|
||||
max_bytes: int = MAX_LOG_SIZE,
|
||||
level: int = logging.INFO,
|
||||
encrypt: bool = False,
|
||||
crypto_engine=None,
|
||||
suppress_stdout: bool = True,
|
||||
):
|
||||
self.module_name = module_name
|
||||
self.logger = logging.getLogger(f"bb.{module_name}")
|
||||
self.logger.setLevel(level)
|
||||
self.logger.propagate = False
|
||||
|
||||
# Clear existing handlers to prevent duplicates
|
||||
self.logger.handlers.clear()
|
||||
|
||||
self._file_handler = EncryptedLogHandler(
|
||||
module_name=module_name,
|
||||
log_dir=log_dir,
|
||||
max_bytes=max_bytes,
|
||||
encrypt=encrypt,
|
||||
crypto_engine=crypto_engine,
|
||||
)
|
||||
self._file_handler.setLevel(level)
|
||||
self.logger.addHandler(self._file_handler)
|
||||
|
||||
if not suppress_stdout:
|
||||
console = logging.StreamHandler()
|
||||
console.setLevel(level)
|
||||
fmt = logging.Formatter(
|
||||
"[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
console.setFormatter(fmt)
|
||||
self.logger.addHandler(console)
|
||||
|
||||
def debug(self, msg: str, *args, **kwargs):
|
||||
self.logger.debug(msg, *args, **kwargs)
|
||||
|
||||
def info(self, msg: str, *args, **kwargs):
|
||||
self.logger.info(msg, *args, **kwargs)
|
||||
|
||||
def warning(self, msg: str, *args, **kwargs):
|
||||
self.logger.warning(msg, *args, **kwargs)
|
||||
|
||||
def error(self, msg: str, *args, **kwargs):
|
||||
self.logger.error(msg, *args, **kwargs)
|
||||
|
||||
def critical(self, msg: str, *args, **kwargs):
|
||||
self.logger.critical(msg, *args, **kwargs)
|
||||
|
||||
def set_level(self, level: int):
|
||||
self.logger.setLevel(level)
|
||||
self._file_handler.setLevel(level)
|
||||
|
||||
def close(self):
|
||||
self._file_handler.close()
|
||||
self.logger.removeHandler(self._file_handler)
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Network interface helpers, BPF compilation, gratuitous ARP. No scapy dependency."""
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import fcntl
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
SIOCGIFADDR = 0x8915
|
||||
SIOCGIFHWADDR = 0x8927
|
||||
SIOCSIFHWADDR = 0x8924
|
||||
SIOCGIFFLAGS = 0x8913
|
||||
SIOCSIFFLAGS = 0x8914
|
||||
IFF_UP = 0x1
|
||||
IFF_RUNNING = 0x40
|
||||
ARPHRD_ETHER = 1
|
||||
|
||||
_libpcap = None
|
||||
|
||||
|
||||
def _get_libpcap():
|
||||
global _libpcap
|
||||
if _libpcap is None:
|
||||
lib = ctypes.util.find_library("pcap")
|
||||
if not lib:
|
||||
raise OSError("libpcap not found")
|
||||
_libpcap = ctypes.CDLL(lib)
|
||||
return _libpcap
|
||||
|
||||
|
||||
def _ifreq(ifname: str) -> bytes:
|
||||
return struct.pack("256s", ifname.encode("utf-8")[:15])
|
||||
|
||||
|
||||
def get_all_interfaces() -> List[str]:
|
||||
interfaces = []
|
||||
net_path = Path("/sys/class/net")
|
||||
if net_path.exists():
|
||||
for entry in net_path.iterdir():
|
||||
name = entry.name
|
||||
if name != "lo":
|
||||
interfaces.append(name)
|
||||
return sorted(interfaces)
|
||||
|
||||
|
||||
def get_primary_interface() -> Optional[str]:
|
||||
try:
|
||||
with open("/proc/net/route", "r") as f:
|
||||
for line in f.readlines()[1:]:
|
||||
fields = line.strip().split()
|
||||
if fields[1] == "00000000":
|
||||
return fields[0]
|
||||
except (IOError, IndexError):
|
||||
pass
|
||||
interfaces = get_all_interfaces()
|
||||
for iface in interfaces:
|
||||
if iface.startswith(("eth", "en")):
|
||||
return iface
|
||||
return interfaces[0] if interfaces else None
|
||||
|
||||
|
||||
def get_bridge_candidates() -> List[str]:
|
||||
candidates = []
|
||||
for iface in get_all_interfaces():
|
||||
if iface.startswith(("eth", "en", "usb")):
|
||||
candidates.append(iface)
|
||||
return candidates
|
||||
|
||||
|
||||
def get_wifi_interfaces() -> List[str]:
|
||||
interfaces = []
|
||||
for iface in get_all_interfaces():
|
||||
phy_path = Path(f"/sys/class/net/{iface}/phy80211")
|
||||
wireless_path = Path(f"/sys/class/net/{iface}/wireless")
|
||||
if phy_path.exists() or wireless_path.exists() or iface.startswith("wlan"):
|
||||
interfaces.append(iface)
|
||||
return interfaces
|
||||
|
||||
|
||||
def get_mac(interface: str) -> str:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
info = fcntl.ioctl(sock.fileno(), SIOCGIFHWADDR, _ifreq(interface))
|
||||
mac_bytes = info[18:24]
|
||||
return ":".join(f"{b:02x}" for b in mac_bytes)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def set_mac(interface: str, mac: str) -> None:
|
||||
was_up = is_interface_up(interface)
|
||||
if was_up:
|
||||
interface_down(interface)
|
||||
mac_bytes = bytes(int(b, 16) for b in mac.split(":"))
|
||||
ifreq = struct.pack("16sH6s8s", interface.encode()[:15], ARPHRD_ETHER, mac_bytes, b"\x00" * 8)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
fcntl.ioctl(sock.fileno(), SIOCSIFHWADDR, ifreq)
|
||||
finally:
|
||||
sock.close()
|
||||
if was_up:
|
||||
interface_up(interface)
|
||||
|
||||
|
||||
def get_ip(interface: str) -> Optional[str]:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
info = fcntl.ioctl(sock.fileno(), SIOCGIFADDR, _ifreq(interface))
|
||||
return socket.inet_ntoa(info[20:24])
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def is_interface_up(interface: str) -> bool:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
result = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, _ifreq(interface))
|
||||
flags = struct.unpack("16sH", result[:18])[1]
|
||||
return bool(flags & IFF_UP)
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def interface_up(interface: str) -> None:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
result = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, _ifreq(interface))
|
||||
flags = struct.unpack("16sH", result[:18])[1]
|
||||
flags |= IFF_UP | IFF_RUNNING
|
||||
ifreq = struct.pack("16sH", interface.encode()[:15], flags)
|
||||
fcntl.ioctl(sock.fileno(), SIOCSIFFLAGS, ifreq)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def interface_down(interface: str) -> None:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
result = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, _ifreq(interface))
|
||||
flags = struct.unpack("16sH", result[:18])[1]
|
||||
flags &= ~IFF_UP
|
||||
ifreq = struct.pack("16sH", interface.encode()[:15], flags)
|
||||
fcntl.ioctl(sock.fileno(), SIOCSIFFLAGS, ifreq)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def create_vlan_interface(parent: str, vlan_id: int) -> str:
|
||||
vlan_iface = f"{parent}.{vlan_id}"
|
||||
subprocess.run(
|
||||
["ip", "link", "add", "link", parent, "name", vlan_iface,
|
||||
"type", "vlan", "id", str(vlan_id)],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
interface_up(vlan_iface)
|
||||
return vlan_iface
|
||||
|
||||
|
||||
def compile_bpf(expression: str, snaplen: int = 65535, linktype: int = 1) -> bytes:
|
||||
pcap = _get_libpcap()
|
||||
|
||||
class bpf_insn(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("code", ctypes.c_ushort),
|
||||
("jt", ctypes.c_ubyte),
|
||||
("jf", ctypes.c_ubyte),
|
||||
("k", ctypes.c_uint32),
|
||||
]
|
||||
|
||||
class bpf_program(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("bf_len", ctypes.c_uint),
|
||||
("bf_insns", ctypes.POINTER(bpf_insn)),
|
||||
]
|
||||
|
||||
handle = pcap.pcap_open_dead(ctypes.c_int(linktype), ctypes.c_int(snaplen))
|
||||
if not handle:
|
||||
raise RuntimeError("pcap_open_dead failed")
|
||||
|
||||
prog = bpf_program()
|
||||
expr_bytes = expression.encode("utf-8")
|
||||
ret = pcap.pcap_compile(
|
||||
handle,
|
||||
ctypes.byref(prog),
|
||||
ctypes.c_char_p(expr_bytes),
|
||||
ctypes.c_int(1),
|
||||
ctypes.c_uint32(0xFFFFFFFF),
|
||||
)
|
||||
if ret != 0:
|
||||
err = pcap.pcap_geterr(handle)
|
||||
pcap.pcap_close(handle)
|
||||
if err:
|
||||
raise ValueError(f"BPF compile failed: {ctypes.string_at(err).decode()}")
|
||||
raise ValueError("BPF compile failed")
|
||||
|
||||
insn_size = ctypes.sizeof(bpf_insn)
|
||||
result = bytes(ctypes.string_at(prog.bf_insns, prog.bf_len * insn_size))
|
||||
pcap.pcap_freecode(ctypes.byref(prog))
|
||||
pcap.pcap_close(handle)
|
||||
return result
|
||||
|
||||
|
||||
def send_gratuitous_arp(interface: str, ip: str, mac: str) -> None:
|
||||
ETH_P_ARP = 0x0806
|
||||
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ARP))
|
||||
try:
|
||||
sock.bind((interface, 0))
|
||||
src_mac = bytes(int(b, 16) for b in mac.split(":"))
|
||||
dst_mac = b"\xff" * 6
|
||||
src_ip = socket.inet_aton(ip)
|
||||
|
||||
# Ethernet header
|
||||
frame = dst_mac + src_mac + struct.pack("!H", ETH_P_ARP)
|
||||
# ARP: hardware=Ethernet, proto=IPv4, hlen=6, plen=4, op=reply(2)
|
||||
frame += struct.pack("!HHBBH", 1, 0x0800, 6, 4, 2)
|
||||
# sender MAC + IP
|
||||
frame += src_mac + src_ip
|
||||
# target MAC + IP (broadcast)
|
||||
frame += dst_mac + src_ip
|
||||
|
||||
for _ in range(3):
|
||||
sock.send(frame)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def mac_to_bytes(mac: str) -> bytes:
|
||||
return bytes(int(b, 16) for b in mac.split(":"))
|
||||
|
||||
|
||||
def bytes_to_mac(data: bytes) -> str:
|
||||
return ":".join(f"{b:02x}" for b in data)
|
||||
|
||||
|
||||
def ip_in_subnet(ip: str, cidr: str) -> bool:
|
||||
network, prefix_len = cidr.split("/")
|
||||
prefix_len = int(prefix_len)
|
||||
ip_int = struct.unpack("!I", socket.inet_aton(ip))[0]
|
||||
net_int = struct.unpack("!I", socket.inet_aton(network))[0]
|
||||
mask = (0xFFFFFFFF << (32 - prefix_len)) & 0xFFFFFFFF
|
||||
return (ip_int & mask) == (net_int & mask)
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Root/capability checks, privilege dropping, directory permission enforcement."""
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import grp
|
||||
import os
|
||||
import pwd
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Linux capability constants
|
||||
CAP_NET_RAW = 13
|
||||
CAP_NET_ADMIN = 15
|
||||
CAP_SYS_ADMIN = 21
|
||||
CAP_DAC_OVERRIDE = 1
|
||||
|
||||
# capget/capset structures
|
||||
_LINUX_CAPABILITY_VERSION_3 = 0x20080522
|
||||
_VFS_CAP_REVISION_2 = 0x02000000
|
||||
|
||||
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
|
||||
|
||||
|
||||
class _cap_header(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("version", ctypes.c_uint32),
|
||||
("pid", ctypes.c_int),
|
||||
]
|
||||
|
||||
|
||||
class _cap_data(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("effective", ctypes.c_uint32),
|
||||
("permitted", ctypes.c_uint32),
|
||||
("inheritable", ctypes.c_uint32),
|
||||
]
|
||||
|
||||
|
||||
def check_root() -> bool:
|
||||
return os.geteuid() == 0
|
||||
|
||||
|
||||
def check_capability(cap: int) -> bool:
|
||||
if check_root():
|
||||
return True
|
||||
|
||||
try:
|
||||
header = _cap_header(version=_LINUX_CAPABILITY_VERSION_3, pid=0)
|
||||
# Version 3 uses 2 data structs for 64-bit capability sets
|
||||
data = (_cap_data * 2)()
|
||||
ret = _libc.capget(ctypes.byref(header), data)
|
||||
if ret != 0:
|
||||
return False
|
||||
|
||||
idx = cap // 32
|
||||
bit = 1 << (cap % 32)
|
||||
return bool(data[idx].effective & bit)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def has_net_raw() -> bool:
|
||||
return check_capability(CAP_NET_RAW)
|
||||
|
||||
|
||||
def has_net_admin() -> bool:
|
||||
return check_capability(CAP_NET_ADMIN)
|
||||
|
||||
|
||||
def check_required_capabilities() -> dict:
|
||||
return {
|
||||
"root": check_root(),
|
||||
"CAP_NET_RAW": check_capability(CAP_NET_RAW),
|
||||
"CAP_NET_ADMIN": check_capability(CAP_NET_ADMIN),
|
||||
"CAP_SYS_ADMIN": check_capability(CAP_SYS_ADMIN),
|
||||
}
|
||||
|
||||
|
||||
def drop_privileges(
|
||||
username: str = "nobody",
|
||||
groupname: Optional[str] = None,
|
||||
keep_caps: Optional[list] = None,
|
||||
) -> bool:
|
||||
if not check_root():
|
||||
return False
|
||||
|
||||
try:
|
||||
pw = pwd.getpwnam(username)
|
||||
uid = pw.pw_uid
|
||||
gid = pw.pw_gid
|
||||
|
||||
if groupname:
|
||||
gr = grp.getgrnam(groupname)
|
||||
gid = gr.gr_gid
|
||||
|
||||
# Set supplementary groups
|
||||
os.setgroups([])
|
||||
|
||||
# Set GID first (can't change after dropping root)
|
||||
os.setregid(gid, gid)
|
||||
os.setreuid(uid, uid)
|
||||
|
||||
# Verify drop
|
||||
if os.geteuid() == 0:
|
||||
return False
|
||||
|
||||
# Re-apply specific capabilities if requested
|
||||
if keep_caps:
|
||||
_set_keepcaps()
|
||||
_apply_caps(keep_caps)
|
||||
|
||||
return True
|
||||
except (KeyError, PermissionError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _set_keepcaps():
|
||||
PR_SET_KEEPCAPS = 8
|
||||
_libc.prctl(PR_SET_KEEPCAPS, 1, 0, 0, 0)
|
||||
|
||||
|
||||
def _apply_caps(caps: list):
|
||||
header = _cap_header(version=_LINUX_CAPABILITY_VERSION_3, pid=0)
|
||||
data = (_cap_data * 2)()
|
||||
|
||||
for cap in caps:
|
||||
idx = cap // 32
|
||||
bit = 1 << (cap % 32)
|
||||
data[idx].effective |= bit
|
||||
data[idx].permitted |= bit
|
||||
|
||||
_libc.capset(ctypes.byref(header), data)
|
||||
|
||||
|
||||
def enforce_directory_permissions(
|
||||
path: str,
|
||||
mode: int = 0o700,
|
||||
recursive: bool = True,
|
||||
) -> int:
|
||||
count = 0
|
||||
target = Path(path)
|
||||
|
||||
if not target.exists():
|
||||
target.mkdir(parents=True, mode=mode)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
current = target.stat().st_mode & 0o777
|
||||
if current != mode:
|
||||
target.chmod(mode)
|
||||
count += 1
|
||||
|
||||
if recursive:
|
||||
for item in target.rglob("*"):
|
||||
if item.is_dir():
|
||||
item_mode = item.stat().st_mode & 0o777
|
||||
if item_mode != mode:
|
||||
item.chmod(mode)
|
||||
count += 1
|
||||
elif item.is_file():
|
||||
file_mode = mode & 0o700 # Files get owner-only based on dir mode
|
||||
item_mode = item.stat().st_mode & 0o777
|
||||
if item_mode != file_mode:
|
||||
item.chmod(file_mode)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def secure_path(path: str, owner_uid: Optional[int] = None) -> bool:
|
||||
try:
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return False
|
||||
|
||||
# Set ownership if specified
|
||||
if owner_uid is not None:
|
||||
os.chown(path, owner_uid, owner_uid)
|
||||
|
||||
# Restrict permissions
|
||||
if p.is_dir():
|
||||
p.chmod(0o700)
|
||||
else:
|
||||
p.chmod(0o600)
|
||||
|
||||
return True
|
||||
except (OSError, PermissionError):
|
||||
return False
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hardware tier detection and resource monitoring via /proc and /sys."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
TIER_OPI_ZERO3 = "opi_zero3"
|
||||
TIER_PI_ZERO = "pi_zero"
|
||||
TIER_GENERIC = "generic"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HardwareInfo:
|
||||
tier: str
|
||||
model: str
|
||||
cpu_model: str
|
||||
cpu_cores: int
|
||||
total_ram_mb: int
|
||||
architecture: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceUsage:
|
||||
ram_used_mb: float
|
||||
ram_total_mb: float
|
||||
ram_percent: float
|
||||
cpu_percent: float # System-wide, from /proc/stat delta
|
||||
disk_used_pct: float
|
||||
disk_free_mb: float
|
||||
temperature_c: Optional[float]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessResources:
|
||||
pid: int
|
||||
name: str
|
||||
rss_mb: float
|
||||
vms_mb: float
|
||||
cpu_ticks: int
|
||||
state: str
|
||||
threads: int
|
||||
|
||||
|
||||
def get_hardware_tier() -> str:
|
||||
info = detect_hardware()
|
||||
return info.tier
|
||||
|
||||
|
||||
def detect_hardware() -> HardwareInfo:
|
||||
model = _read_device_model()
|
||||
cpu_model, cpu_cores, arch = _read_cpuinfo()
|
||||
|
||||
if "Orange Pi Zero3" in model or "Orange Pi Zero 3" in model:
|
||||
tier = TIER_OPI_ZERO3
|
||||
elif "Raspberry Pi Zero 2" in model or "Raspberry Pi Zero2" in model:
|
||||
tier = TIER_PI_ZERO
|
||||
elif "Raspberry Pi" in model and ("Zero" in model or "zero" in model):
|
||||
tier = TIER_PI_ZERO
|
||||
elif "Allwinner" in cpu_model or "H618" in cpu_model:
|
||||
# Fallback detection via CPU for OPi Zero 3
|
||||
tier = TIER_OPI_ZERO3
|
||||
elif "BCM2835" in cpu_model or "BCM2710" in cpu_model:
|
||||
tier = TIER_PI_ZERO
|
||||
else:
|
||||
tier = TIER_GENERIC
|
||||
|
||||
total_ram = _read_total_ram_mb()
|
||||
|
||||
return HardwareInfo(
|
||||
tier=tier,
|
||||
model=model or "Unknown",
|
||||
cpu_model=cpu_model,
|
||||
cpu_cores=cpu_cores,
|
||||
total_ram_mb=total_ram,
|
||||
architecture=arch,
|
||||
)
|
||||
|
||||
|
||||
def _read_device_model() -> str:
|
||||
dt_model = Path("/proc/device-tree/model")
|
||||
if dt_model.exists():
|
||||
try:
|
||||
return dt_model.read_text().strip().rstrip("\x00")
|
||||
except IOError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _read_cpuinfo() -> Tuple[str, int, str]:
|
||||
cpu_model = ""
|
||||
cores = 0
|
||||
arch = "unknown"
|
||||
|
||||
try:
|
||||
with open("/proc/cpuinfo", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
for line in content.splitlines():
|
||||
if line.startswith("model name") or line.startswith("Hardware"):
|
||||
cpu_model = line.split(":", 1)[1].strip()
|
||||
if line.startswith("processor"):
|
||||
cores += 1
|
||||
|
||||
# Get architecture
|
||||
with open("/proc/cpuinfo", "r") as f:
|
||||
first_lines = f.read(4096)
|
||||
if "aarch64" in first_lines.lower() or "ARMv8" in first_lines:
|
||||
arch = "aarch64"
|
||||
elif "armv7" in first_lines.lower():
|
||||
arch = "armv7l"
|
||||
else:
|
||||
import platform
|
||||
arch = platform.machine()
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
return cpu_model, max(cores, 1), arch
|
||||
|
||||
|
||||
def _read_total_ram_mb() -> int:
|
||||
try:
|
||||
with open("/proc/meminfo", "r") as f:
|
||||
for line in f:
|
||||
if line.startswith("MemTotal:"):
|
||||
kb = int(line.split()[1])
|
||||
return kb // 1024
|
||||
except IOError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def get_memory_info() -> Dict[str, int]:
|
||||
info = {}
|
||||
try:
|
||||
with open("/proc/meminfo", "r") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
key = parts[0].rstrip(":")
|
||||
val = int(parts[1]) # in kB
|
||||
info[key] = val
|
||||
except IOError:
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
def get_resource_usage(disk_path: str = "/") -> ResourceUsage:
|
||||
mem = get_memory_info()
|
||||
total = mem.get("MemTotal", 0)
|
||||
available = mem.get("MemAvailable", mem.get("MemFree", 0))
|
||||
used = total - available
|
||||
pct = (used / total * 100) if total > 0 else 0.0
|
||||
|
||||
stat = os.statvfs(disk_path)
|
||||
disk_total = stat.f_blocks * stat.f_frsize
|
||||
disk_free = stat.f_bavail * stat.f_frsize
|
||||
disk_used_pct = ((disk_total - disk_free) / disk_total * 100) if disk_total > 0 else 0.0
|
||||
|
||||
temp = get_cpu_temperature()
|
||||
|
||||
return ResourceUsage(
|
||||
ram_used_mb=used / 1024,
|
||||
ram_total_mb=total / 1024,
|
||||
ram_percent=pct,
|
||||
cpu_percent=_get_cpu_percent(),
|
||||
disk_used_pct=disk_used_pct,
|
||||
disk_free_mb=disk_free / (1024 * 1024),
|
||||
temperature_c=temp,
|
||||
)
|
||||
|
||||
|
||||
def get_cpu_temperature() -> Optional[float]:
|
||||
thermal_zones = Path("/sys/class/thermal")
|
||||
if thermal_zones.exists():
|
||||
for zone in sorted(thermal_zones.iterdir()):
|
||||
temp_file = zone / "temp"
|
||||
type_file = zone / "type"
|
||||
if temp_file.exists():
|
||||
try:
|
||||
temp_raw = int(temp_file.read_text().strip())
|
||||
return temp_raw / 1000.0
|
||||
except (IOError, ValueError):
|
||||
continue
|
||||
# Fallback for some SBCs
|
||||
hwmon = Path("/sys/class/hwmon")
|
||||
if hwmon.exists():
|
||||
for hw in sorted(hwmon.iterdir()):
|
||||
temp_file = hw / "temp1_input"
|
||||
if temp_file.exists():
|
||||
try:
|
||||
return int(temp_file.read_text().strip()) / 1000.0
|
||||
except (IOError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _get_cpu_percent() -> float:
|
||||
try:
|
||||
with open("/proc/stat", "r") as f:
|
||||
line = f.readline()
|
||||
parts = line.split()
|
||||
# user, nice, system, idle, iowait, irq, softirq, steal
|
||||
total = sum(int(p) for p in parts[1:8])
|
||||
idle = int(parts[4])
|
||||
# Snapshot-based; for delta, caller should sample twice
|
||||
if total > 0:
|
||||
return ((total - idle) / total) * 100
|
||||
except (IOError, IndexError, ValueError):
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def get_process_resources(pid: int) -> Optional[ProcessResources]:
|
||||
try:
|
||||
stat_path = f"/proc/{pid}/stat"
|
||||
status_path = f"/proc/{pid}/status"
|
||||
|
||||
with open(stat_path, "r") as f:
|
||||
stat_line = f.read()
|
||||
|
||||
# Parse /proc/PID/stat — comm field can contain spaces/parens
|
||||
match = re.match(r"(\d+) \((.+)\) (.+)", stat_line)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
name = match.group(2)
|
||||
fields = match.group(3).split()
|
||||
state = fields[0]
|
||||
utime = int(fields[11])
|
||||
stime = int(fields[12])
|
||||
threads = int(fields[17])
|
||||
vsize = int(fields[20])
|
||||
rss_pages = int(fields[21])
|
||||
|
||||
page_size = os.sysconf("SC_PAGE_SIZE")
|
||||
rss_mb = (rss_pages * page_size) / (1024 * 1024)
|
||||
vms_mb = vsize / (1024 * 1024)
|
||||
|
||||
return ProcessResources(
|
||||
pid=pid,
|
||||
name=name,
|
||||
rss_mb=rss_mb,
|
||||
vms_mb=vms_mb,
|
||||
cpu_ticks=utime + stime,
|
||||
state=state,
|
||||
threads=threads,
|
||||
)
|
||||
except (IOError, OSError, IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def get_disk_usage(path: str = "/") -> Tuple[float, float, float]:
|
||||
stat = os.statvfs(path)
|
||||
total_gb = (stat.f_blocks * stat.f_frsize) / (1024 ** 3)
|
||||
free_gb = (stat.f_bavail * stat.f_frsize) / (1024 ** 3)
|
||||
used_pct = ((stat.f_blocks - stat.f_bavail) / stat.f_blocks * 100) if stat.f_blocks > 0 else 0
|
||||
return total_gb, free_gb, used_pct
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared stealth helpers: process rename, cmdline spoof, sysctl, timestomp, core dump disable."""
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
PR_SET_NAME = 15
|
||||
RLIMIT_CORE = 4
|
||||
|
||||
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
|
||||
|
||||
|
||||
def rename_process(name: str) -> bool:
|
||||
name_bytes = name.encode("utf-8")[:15]
|
||||
ret = _libc.prctl(PR_SET_NAME, name_bytes, 0, 0, 0)
|
||||
return ret == 0
|
||||
|
||||
|
||||
def spoof_cmdline(fake_cmdline: str) -> bool:
|
||||
try:
|
||||
cmdline_path = f"/proc/{os.getpid()}/cmdline"
|
||||
# Read current cmdline to get its length for overwrite
|
||||
with open(cmdline_path, "rb") as f:
|
||||
original = f.read()
|
||||
|
||||
# On Linux, argv[0] is writable via /proc/self/comm and prctl,
|
||||
# but /proc/self/cmdline maps to the actual process memory.
|
||||
# We overwrite via ctypes access to argv.
|
||||
libc = _libc
|
||||
|
||||
# Get argc/argv from /proc/self/cmdline
|
||||
argc = ctypes.c_int()
|
||||
argv_ptr = ctypes.POINTER(ctypes.c_char_p)()
|
||||
|
||||
# Alternative: write to /proc/self/comm (max 15 chars)
|
||||
comm_path = f"/proc/{os.getpid()}/comm"
|
||||
try:
|
||||
with open(comm_path, "w") as f:
|
||||
f.write(fake_cmdline[:15])
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
# For full cmdline spoofing, overwrite argv[0] in memory
|
||||
import sys
|
||||
if hasattr(sys, "argv") and sys.argv:
|
||||
# Replace argv contents via ctypes
|
||||
fake_bytes = fake_cmdline.encode("utf-8")
|
||||
argv0_addr = id(sys.argv[0])
|
||||
# Python string internals - this is fragile but works for ps display
|
||||
sys.argv[0] = fake_cmdline
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def set_sysctl(key: str, value: str) -> bool:
|
||||
sysctl_path = Path("/proc/sys") / key.replace(".", "/")
|
||||
try:
|
||||
with open(sysctl_path, "w") as f:
|
||||
f.write(value)
|
||||
return True
|
||||
except (IOError, PermissionError):
|
||||
return False
|
||||
|
||||
|
||||
def set_ttl(ttl: int) -> bool:
|
||||
return set_sysctl("net.ipv4.ip_default_ttl", str(ttl))
|
||||
|
||||
|
||||
def set_tcp_window(size: int) -> bool:
|
||||
# Set TCP initial window via rmem/wmem defaults
|
||||
success = set_sysctl("net.ipv4.tcp_rmem", f"4096 {size} {size * 4}")
|
||||
success &= set_sysctl("net.ipv4.tcp_wmem", f"4096 {size} {size * 4}")
|
||||
return success
|
||||
|
||||
|
||||
def set_tcp_timestamps(enabled: bool) -> bool:
|
||||
return set_sysctl("net.ipv4.tcp_timestamps", "1" if enabled else "0")
|
||||
|
||||
|
||||
def timestomp(path: str, reference: Optional[str] = None, epoch: Optional[float] = None) -> bool:
|
||||
try:
|
||||
if reference:
|
||||
ref_stat = os.stat(reference)
|
||||
target_time = ref_stat.st_mtime
|
||||
elif epoch:
|
||||
target_time = epoch
|
||||
else:
|
||||
# Default: OS install date from /var/log/installer or filesystem birth
|
||||
target_time = _get_os_install_time()
|
||||
|
||||
os.utime(path, (target_time, target_time))
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
return False
|
||||
|
||||
|
||||
def timestomp_recursive(directory: str, reference: Optional[str] = None, epoch: Optional[float] = None) -> int:
|
||||
count = 0
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for name in files + dirs:
|
||||
full_path = os.path.join(root, name)
|
||||
if timestomp(full_path, reference=reference, epoch=epoch):
|
||||
count += 1
|
||||
if timestomp(directory, reference=reference, epoch=epoch):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _get_os_install_time() -> float:
|
||||
candidates = [
|
||||
"/var/log/installer/syslog",
|
||||
"/var/log/installer/status",
|
||||
"/etc/hostname",
|
||||
"/etc/machine-id",
|
||||
]
|
||||
for path in candidates:
|
||||
try:
|
||||
return os.stat(path).st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
# Fallback: 90 days ago
|
||||
return time.time() - (90 * 86400)
|
||||
|
||||
|
||||
def disable_core_dumps() -> bool:
|
||||
try:
|
||||
# RLIMIT_CORE = 4 on Linux
|
||||
class rlimit(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("rlim_cur", ctypes.c_ulong),
|
||||
("rlim_max", ctypes.c_ulong),
|
||||
]
|
||||
|
||||
limit = rlimit(0, 0)
|
||||
ret = _libc.setrlimit(RLIMIT_CORE, ctypes.byref(limit))
|
||||
if ret != 0:
|
||||
return False
|
||||
|
||||
# Also set via sysctl and /proc
|
||||
set_sysctl("kernel.core_pattern", "|/bin/false")
|
||||
set_sysctl("fs.suid_dumpable", "0")
|
||||
|
||||
# prctl to prevent core for this process and children
|
||||
PR_SET_DUMPABLE = 4
|
||||
_libc.prctl(PR_SET_DUMPABLE, 0, 0, 0, 0)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def hide_from_history() -> bool:
|
||||
try:
|
||||
os.environ["HISTFILE"] = "/dev/null"
|
||||
os.environ["HISTSIZE"] = "0"
|
||||
os.environ["HISTFILESIZE"] = "0"
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
Reference in New Issue
Block a user