"""Values for banner fields ({hostname}, {ip}, ...) and widgets: blocks of lines from built-in functions, scripts, commands or files. "live" widgets run at login with a short timeout; "cached" ones are run by the das-banner service, and the login only reads their last output from CACHE_DIR. """ import concurrent.futures import datetime import hashlib import json import os import socket import subprocess import time from . import CACHE_DIR, DATA_DIR, colors from .i18n import t LIVE_TIMEOUT = 1.5 CACHED_TIMEOUT = 60 # --- field values ----------------------------------------------------------------- def _read(path): try: with open(path, encoding="utf-8", errors="replace") as f: return f.read() except OSError: return "" def _ips(): try: out = subprocess.run(["ip", "-4", "-o", "addr", "show", "scope", "global"], capture_output=True, text=True, timeout=1).stdout ips = [line.split()[3].split("/")[0] for line in out.splitlines() if len(line.split()) > 3] if ips: return ips except (OSError, subprocess.SubprocessError): pass try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.connect(("192.0.2.1", 9)) # no packet is sent return [s.getsockname()[0]] except OSError: return [] def _os_name(): for line in _read("/etc/os-release").splitlines(): if line.startswith("PRETTY_NAME="): return line.split("=", 1)[1].strip().strip('"') return "Linux" def _duration(seconds): days, rest = divmod(int(seconds), 86400) hours, rest = divmod(rest, 3600) minutes = rest // 60 parts = [] if days: parts.append(t(f"{days} дн", f"{days} d")) if hours or days: parts.append(t(f"{hours} год", f"{hours} h")) if not days: parts.append(t(f"{minutes} хв", f"{minutes} min")) return " ".join(parts) def _uptime(): try: return _duration(float(_read("/proc/uptime").split()[0])) except (IndexError, ValueError): return "?" def _meminfo(): info = {} for line in _read("/proc/meminfo").splitlines(): key, _, value = line.partition(":") try: info[key] = int(value.split()[0]) * 1024 except (IndexError, ValueError): pass return info def human(n): for unit in ("B", "K", "M", "G", "T", "P"): if abs(n) < 1024 or unit == "P": return f"{n:.0f}{unit}" if unit in ("B", "K") or n >= 100 else f"{n:.1f}{unit}" n /= 1024 return str(n) def _mem(): m = _meminfo() total = m.get("MemTotal", 0) used = total - m.get("MemAvailable", 0) return f"{human(used)}/{human(total)}" def _cpu(): for line in _read("/proc/cpuinfo").splitlines(): if line.startswith("model name"): return " ".join(line.split(":", 1)[1].split()) return os.uname().machine def _users(): try: out = subprocess.run(["who"], capture_output=True, text=True, timeout=1).stdout return str(len({line.split()[0] for line in out.splitlines() if line.strip()})) except (OSError, subprocess.SubprocessError): return "?" PROVIDERS = { "hostname": lambda: socket.gethostname().split(".")[0], "fqdn": socket.getfqdn, "ip": lambda: (_ips() or ["?"])[0], "ips": lambda: " ".join(_ips()), "os": _os_name, "kernel": lambda: os.uname().release, "arch": lambda: os.uname().machine, "uptime": _uptime, "load": lambda: " ".join(_read("/proc/loadavg").split()[:3]), "mem": _mem, "cpu": _cpu, "cores": lambda: str(os.cpu_count() or 1), "date": lambda: datetime.datetime.now().strftime("%Y-%m-%d %H:%M"), "users": _users, "user": lambda: os.environ.get("USER") or os.environ.get("LOGNAME") or "?", } VAR_HELP = { "hostname": ("ім'я комп'ютера", "host name"), "fqdn": ("повне ім'я в мережі", "fully qualified name"), "ip": ("перша IPv4-адреса", "first IPv4 address"), "ips": ("усі IPv4-адреси", "all IPv4 addresses"), "os": ("назва системи", "system name"), "kernel": ("версія ядра", "kernel version"), "arch": ("архітектура", "architecture"), "uptime": ("час роботи", "uptime"), "load": ("навантаження", "load average"), "mem": ("пам'ять зайнято/всього", "memory used/total"), "cpu": ("процесор", "processor"), "cores": ("кількість ядер", "number of cores"), "date": ("дата й час", "date and time"), "users": ("користувачів у системі", "users logged in"), "user": ("хто входить", "who logs in"), } class Values(dict): """Field values, computed only when a field asks for them.""" def __init__(self, user_vars=None): super().__init__() self.user_vars = dict(user_vars or {}) def get(self, key, default=None): if key in self.user_vars: return self.user_vars[key] if key not in self and key in PROVIDERS: try: self[key] = PROVIDERS[key]() except Exception: # a broken value must not break the login self[key] = "?" return super().get(key, default) # --- built-in widgets ------------------------------------------------------------- def _bar(percent, width=10): full = round(percent / 100 * width) color = "green" if percent < 70 else ("yellow" if percent < 90 else "red") return (colors.sgr(color, None, True) + "█" * full + colors.sgr("bright_black") + "░" * (width - full) + colors.RESET) PSEUDO_FS = {"proc", "sysfs", "devtmpfs", "devpts", "tmpfs", "cgroup", "cgroup2", "securityfs", "pstore", "efivarfs", "bpf", "debugfs", "tracefs", "configfs", "fusectl", "mqueue", "hugetlbfs", "autofs", "binfmt_misc", "overlay", "squashfs", "nsfs", "ramfs", "rpc_pipefs", "fuse.portal", "fuse.gvfsd-fuse", "iso9660", "zram"} def _mounts(): seen, out = set(), [] for line in _read("/proc/mounts").splitlines(): parts = line.split() if len(parts) < 3 or parts[2] in PSEUDO_FS or not parts[0].startswith("/"): continue mount = parts[1].replace("\\040", " ") if parts[0] in seen or mount.startswith(("/snap", "/var/lib/docker", "/.snapshots", "/run")): continue seen.add(parts[0]) out.append(mount) return out def w_disks(args, values): lines = [colors.sgr("white", None, True) + t("Диски:", "Disks:") + colors.RESET] rows = [] for mount in args.get("mounts") or _mounts(): try: st = os.statvfs(mount) except OSError: continue total = st.f_blocks * st.f_frsize if not total: continue used = total - st.f_bfree * st.f_frsize rows.append((mount, total, used)) width = max([len(r[0]) for r in rows] + [1]) for mount, total, used in rows: pct = round(used * 100 / total) lines.append(f" {mount.ljust(width)} {human(total):>6} {_bar(pct)} {pct:3d}%") return lines def w_memory(args, values): m = _meminfo() lines = [] for label, total, free in ((t("Пам'ять", "Memory"), m.get("MemTotal", 0), m.get("MemAvailable", 0)), ("Swap", m.get("SwapTotal", 0), m.get("SwapFree", 0))): if not total: continue pct = round((total - free) * 100 / total) lines.append(f"{colors.sgr('white', None, True)}{label + ':':<9}{colors.RESET} " f"{human(total - free):>6}/{human(total):<6} {_bar(pct)} {pct:3d}%") return lines def w_uptime(args, values): return [f"{colors.sgr('white', None, True)}{t('Працює:', 'Uptime:')}{colors.RESET} " f"{colors.sgr('red', None, True)}{values.get('uptime')}{colors.RESET}"] def w_load(args, values): return [f"{colors.sgr('white', None, True)}{t('Навантаження:', 'Load:')}{colors.RESET} " f"{values.get('load')} ({t('ядер', 'cores')}: {values.get('cores')})"] def w_network(args, values): lines = [colors.sgr("white", None, True) + t("Мережа:", "Network:") + colors.RESET] try: out = subprocess.run(["ip", "-o", "addr", "show", "scope", "global"], capture_output=True, text=True, timeout=1).stdout except (OSError, subprocess.SubprocessError): return [] for line in out.splitlines(): p = line.split() if len(p) > 3: lines.append(f" {p[1]:<10} {colors.sgr('cyan', None, True)}{p[3]}{colors.RESET}") return lines if len(lines) > 1 else [] BUILTINS = { "uptime": (w_uptime, ("Час роботи", "Uptime")), "load": (w_load, ("Навантаження", "Load average")), "memory": (w_memory, ("Пам'ять і swap", "Memory and swap")), "disks": (w_disks, ("Диски", "Disks")), "network": (w_network, ("Мережа", "Network")), } def widget_dir(): return os.path.join(DATA_DIR, "banner", "widgets") def script_meta(path): """# dbanner-: value lines of a widget script.""" meta = {} for line in _read(path).splitlines()[:20]: if line.startswith("# dbanner-"): key, _, value = line[10:].partition(":") meta[key.strip()] = value.strip() return meta def available(): """(name, title, mode) of every built-in widget and widget script.""" out = [(name, t(*names), "live") for name, (_, names) in BUILTINS.items()] try: scripts = sorted(f for f in os.listdir(widget_dir()) if f.endswith(".sh") and f != "lib.sh") except OSError: scripts = [] for f in scripts: meta = script_meta(os.path.join(widget_dir(), f)) out.append((f[:-3], t(meta.get("title-uk", f[:-3]), meta.get("title-en", f[:-3])), meta.get("mode", "live"))) return out def describe(w): """A short line about a widget for lists.""" kind = w.get("type", "builtin") if kind == "builtin": names = {n: title_ for n, title_, _ in available()} return names.get(w.get("name"), w.get("name", "?")) if kind == "command": return "$ " + w.get("cmd", "") return t("файл ", "file ") + w.get("path", "") def mode_of(w): if w.get("mode"): return w["mode"] if w.get("type", "builtin") == "builtin" and w.get("name") not in BUILTINS: return script_meta(os.path.join(widget_dir(), w.get("name", "") + ".sh")).get("mode", "live") return "live" def interval_of(w): if w.get("interval"): return int(w["interval"]) if w.get("type", "builtin") == "builtin": meta = script_meta(os.path.join(widget_dir(), w.get("name", "") + ".sh")) if meta.get("interval", "").isdigit(): return int(meta["interval"]) return 600 def cache_path(w): key = hashlib.sha1(json.dumps(w, sort_keys=True).encode()).hexdigest()[:16] return os.path.join(CACHE_DIR, key + ".txt") def _env(design_vars): env = dict(os.environ) for key, value in (design_vars or {}).items(): env["DBANNER_" + key.upper()] = str(value) env["DBANNER_LANG"] = "uk" if t("uk", "en") == "uk" else "en" return env def run(w, values, design_vars=None, timeout=None): """Runs a widget now and returns its lines (empty when it fails).""" kind = w.get("type", "builtin") try: if kind == "file": text = _read(os.path.expanduser(w.get("path", ""))) lines = text.rstrip("\n").split("\n") if text.strip() else [] elif kind == "builtin" and w.get("name") in BUILTINS: lines = BUILTINS[w["name"]][0](w.get("args") or {}, values) else: if kind == "builtin": cmd = [os.path.join(widget_dir(), w.get("name", "") + ".sh")] else: cmd = ["sh", "-c", w.get("cmd", "")] limit = timeout or float(w.get("timeout") or (CACHED_TIMEOUT if mode_of(w) == "cached" else LIVE_TIMEOUT)) proc = subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=limit, env=_env(design_vars), stdin=subprocess.DEVNULL) out = proc.stdout.rstrip("\n") lines = out.split("\n") if out.strip() else [] except Exception: # a broken widget must not break the login return [] if lines and w.get("title"): lines = [colors.sgr("white", None, True) + w["title"] + colors.RESET] + lines return lines def read_cache(w): text = _read(cache_path(w)) return text.rstrip("\n").split("\n") if text.strip() else [] def blocks(design, values, use_cache=True): """Lines of every widget of a design, in order; live ones run in parallel.""" widgets = design.get("widgets", []) result = [[] for _ in widgets] jobs = {} # No "with": leaving it would wait for a widget that hangs pool = concurrent.futures.ThreadPoolExecutor(max_workers=8) for i, w in enumerate(widgets): if w.get("enabled") is False: continue if use_cache and mode_of(w) == "cached": result[i] = read_cache(w) else: jobs[pool.submit(run, w, values, design.get("vars"))] = i try: for fut in concurrent.futures.as_completed(jobs, timeout=LIVE_TIMEOUT + 1 if use_cache else None): result[jobs[fut]] = fut.result() except concurrent.futures.TimeoutError: pass pool.shutdown(wait=False, cancel_futures=True) return result def refresh(design, force=False): """Runs the cached widgets whose output is older than their interval.""" values = None os.makedirs(CACHE_DIR, mode=0o755, exist_ok=True) for w in design.get("widgets", []): if w.get("enabled") is False or mode_of(w) != "cached": continue path = cache_path(w) try: age = time.time() - os.stat(path).st_mtime except OSError: age = None if not force and age is not None and age < interval_of(w): continue if values is None: values = Values(design.get("vars")) lines = run(w, values, design.get("vars")) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: f.write("\n".join(lines) + ("\n" if lines else "")) os.chmod(tmp, 0o644) os.replace(tmp, path)