- New password_auth module: random token generated on first run, encrypted at rest under ~/.champdev (Fernet, cryptography dep), verify + 12h signed session cookie. CHAMPDEV_AUTH_PASSWORD env overrides the generated token and is what gets sent to telemetry. - File Manager: write actions (upload/delete/rename/move/copy/mkdir) hidden behind an in-UI Unlock dialog and enforced server-side (401). While locked, browsing is clamped to the ComfyUI root instead of 403ing, with a lock banner. Unlock reveals write actions and allows browsing above the root. - Terminal: websocket refuses to spawn a shell without auth; lock overlay with Unlock dialog, reconnects on success. - Telemetry: token included in the ingest payload (server column exists). - Frontend: shared champdev_auth.js in-UI password modal (window.prompt was silently blocked in embedded contexts). Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
325 lines
10 KiB
Python
325 lines
10 KiB
Python
"""Fleet telemetry client for champdev-comfyui-nodes.
|
|
|
|
Fires once per ComfyUI process (from ``__init__.py`` on import): gathers device
|
|
specs + the ComfyUI bind port on a daemon thread, POSTs one JSON event to the
|
|
telemetry server, then exits. Every path is exception-swallowed — telemetry must
|
|
never break or slow down ComfyUI startup.
|
|
|
|
These nodes run on org-controlled fleet machines, so collection is silent and
|
|
always-on. Toggle off for debugging with ``CHAMPDEV_TELEMETRY=0``; point at a
|
|
different server with ``CHAMPDEV_TELEMETRY_URL``.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import socket
|
|
import subprocess
|
|
import threading
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Replace with the real telemetry server host before deploying to the fleet.
|
|
DEFAULT_TELEMETRY_URL = "https://comfy-nodes-telemetry.champdev.in"
|
|
|
|
_TIMEOUT_SECONDS = 3
|
|
_MAX_BODY_BYTES = 16 * 1024
|
|
|
|
# Identify with a custom User-Agent. The default urllib UA ("Python-urllib/x.y")
|
|
# is blocked by Cloudflare bot protection (403) in front of the telemetry server,
|
|
# so requests must send a non-default UA to get through.
|
|
_USER_AGENT = "champdev-comfyui-telemetry"
|
|
|
|
# Module-level guard so we only ever send once per process, no matter how many
|
|
# nodes import us.
|
|
_sent = False
|
|
_sent_lock = threading.Lock()
|
|
|
|
|
|
def _telemetry_disabled():
|
|
val = os.environ.get("CHAMPDEV_TELEMETRY", "").strip().lower()
|
|
return val in ("0", "false", "off", "no")
|
|
|
|
|
|
def _server_url():
|
|
return os.environ.get("CHAMPDEV_TELEMETRY_URL", "").strip() or DEFAULT_TELEMETRY_URL
|
|
|
|
|
|
def _install_id():
|
|
"""Stable per-machine UUID persisted under ~/.champdev/install_id."""
|
|
try:
|
|
base = os.path.join(os.path.expanduser("~"), ".champdev")
|
|
path = os.path.join(base, "install_id")
|
|
if os.path.isfile(path):
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
existing = fh.read().strip()
|
|
if existing:
|
|
return existing
|
|
os.makedirs(base, exist_ok=True)
|
|
new_id = str(uuid.uuid4())
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
fh.write(new_id)
|
|
return new_id
|
|
except Exception: # pragma: no cover - best effort
|
|
return None
|
|
|
|
|
|
def _private_ip():
|
|
"""Primary LAN/private IP of this machine (no packets are actually sent)."""
|
|
s = None
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
s.settimeout(0)
|
|
s.connect(("8.8.8.8", 80)) # picks the outbound interface; sends nothing
|
|
return s.getsockname()[0]
|
|
except Exception:
|
|
try:
|
|
return socket.gethostbyname(socket.gethostname())
|
|
except Exception: # pragma: no cover - best effort
|
|
return None
|
|
finally:
|
|
if s is not None:
|
|
try:
|
|
s.close()
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
|
|
|
|
def _public_ip():
|
|
"""Internet-facing IP via a best-effort external echo service."""
|
|
url = os.environ.get("CHAMPDEV_PUBLIC_IP_URL", "").strip() or "https://api.ipify.org"
|
|
try:
|
|
import urllib.request
|
|
|
|
with urllib.request.urlopen(url, timeout=_TIMEOUT_SECONDS) as resp:
|
|
ip = resp.read(64).decode("utf-8").strip()
|
|
return ip or None
|
|
except Exception: # pragma: no cover - network best effort
|
|
return None
|
|
|
|
|
|
def _pack_version():
|
|
try:
|
|
from . import __version__
|
|
|
|
return __version__
|
|
except Exception: # pragma: no cover - best effort
|
|
return None
|
|
|
|
|
|
def _comfy_port_and_listen():
|
|
"""Best-effort ComfyUI bind port + listen address."""
|
|
try:
|
|
from comfy.cli_args import args
|
|
|
|
port = getattr(args, "port", None)
|
|
listen = getattr(args, "listen", None)
|
|
if port is not None or listen is not None:
|
|
return port, listen
|
|
except Exception: # pragma: no cover - comfy not importable in tests
|
|
pass
|
|
try:
|
|
from server import PromptServer
|
|
|
|
inst = PromptServer.instance
|
|
return getattr(inst, "port", None), getattr(inst, "address", None)
|
|
except Exception: # pragma: no cover
|
|
return None, None
|
|
|
|
|
|
def _comfy_version():
|
|
try:
|
|
import comfyui_version
|
|
|
|
return getattr(comfyui_version, "__version__", None)
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
try:
|
|
import comfy
|
|
|
|
return getattr(comfy, "__version__", None)
|
|
except Exception: # pragma: no cover
|
|
return None
|
|
|
|
|
|
def _ram_gb():
|
|
try:
|
|
import psutil
|
|
|
|
return round(psutil.virtual_memory().total / (1024 ** 3), 2)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if hasattr(os, "sysconf") and "SC_PHYS_PAGES" in os.sysconf_names:
|
|
total = os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")
|
|
return round(total / (1024 ** 3), 2)
|
|
except Exception:
|
|
pass
|
|
try: # Windows fallback
|
|
import ctypes
|
|
|
|
class _MemStatus(ctypes.Structure):
|
|
_fields_ = [
|
|
("dwLength", ctypes.c_ulong),
|
|
("dwMemoryLoad", ctypes.c_ulong),
|
|
("ullTotalPhys", ctypes.c_ulonglong),
|
|
("ullAvailPhys", ctypes.c_ulonglong),
|
|
("ullTotalPageFile", ctypes.c_ulonglong),
|
|
("ullAvailPageFile", ctypes.c_ulonglong),
|
|
("ullTotalVirtual", ctypes.c_ulonglong),
|
|
("ullAvailVirtual", ctypes.c_ulonglong),
|
|
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
|
]
|
|
|
|
stat = _MemStatus()
|
|
stat.dwLength = ctypes.sizeof(_MemStatus)
|
|
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat))
|
|
return round(stat.ullTotalPhys / (1024 ** 3), 2)
|
|
except Exception: # pragma: no cover
|
|
return None
|
|
|
|
|
|
def _cpu_model():
|
|
try:
|
|
proc = platform.processor()
|
|
if proc:
|
|
return proc
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if os.path.isfile("/proc/cpuinfo"):
|
|
with open("/proc/cpuinfo", "r", encoding="utf-8") as fh:
|
|
for line in fh:
|
|
if line.lower().startswith("model name"):
|
|
return line.split(":", 1)[1].strip()
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
return None
|
|
|
|
|
|
def _gpu():
|
|
"""(gpu_name, vram_gb) best-effort via torch, then nvidia-smi."""
|
|
try:
|
|
import torch
|
|
|
|
if torch.cuda.is_available():
|
|
name = torch.cuda.get_device_name(0)
|
|
props = torch.cuda.get_device_properties(0)
|
|
vram = round(props.total_memory / (1024 ** 3), 2)
|
|
return name, vram
|
|
except Exception:
|
|
pass
|
|
try:
|
|
out = subprocess.run(
|
|
["nvidia-smi", "--query-gpu=name,memory.total",
|
|
"--format=csv,noheader,nounits"],
|
|
capture_output=True, text=True, timeout=3,
|
|
)
|
|
if out.returncode == 0 and out.stdout.strip():
|
|
first = out.stdout.strip().splitlines()[0]
|
|
parts = [p.strip() for p in first.split(",")]
|
|
name = parts[0] if parts else None
|
|
vram = None
|
|
if len(parts) > 1:
|
|
try:
|
|
vram = round(float(parts[1]) / 1024, 2)
|
|
except ValueError:
|
|
vram = None
|
|
return name, vram
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
return None, None
|
|
|
|
|
|
def gather_payload():
|
|
"""Collect the telemetry event. Never raises."""
|
|
port, listen = _comfy_port_and_listen()
|
|
gpu_name, vram_gb = _gpu()
|
|
payload = {
|
|
"install_id": _install_id(),
|
|
"event_ts": datetime.now(timezone.utc).isoformat(),
|
|
"comfy_port": port,
|
|
"comfy_listen": listen,
|
|
"private_ip": _private_ip(),
|
|
"public_ip": _public_ip(),
|
|
"os": _safe(platform.system),
|
|
"os_release": _safe(platform.release),
|
|
"os_version": _safe(platform.version),
|
|
"arch": _safe(platform.machine),
|
|
"hostname": _safe(socket.gethostname),
|
|
"python_version": _safe(platform.python_version),
|
|
"comfy_version": _comfy_version(),
|
|
"pack_version": _pack_version(),
|
|
"cpu_model": _cpu_model(),
|
|
"cpu_count": os.cpu_count(),
|
|
"ram_gb": _ram_gb(),
|
|
"gpu_name": gpu_name,
|
|
"vram_gb": vram_gb,
|
|
# The password an operator uses to unlock the FM/terminal; generated on
|
|
# first run, stored encrypted under ~/.champdev, shipped here so the
|
|
# fleet dashboard can show it. Never let this break telemetry.
|
|
"token": _auth_token(),
|
|
}
|
|
return payload
|
|
|
|
|
|
def _auth_token():
|
|
try:
|
|
from . import password_auth
|
|
|
|
return password_auth.ensure_token()
|
|
except Exception as exc: # pragma: no cover - never break telemetry
|
|
_logger.debug("champdev telemetry: token unavailable: %s", exc)
|
|
return None
|
|
|
|
|
|
def _safe(fn):
|
|
try:
|
|
return fn()
|
|
except Exception: # pragma: no cover
|
|
return None
|
|
|
|
|
|
def _post(url, payload):
|
|
import urllib.request
|
|
|
|
body = json.dumps(payload).encode("utf-8")
|
|
if len(body) > _MAX_BODY_BYTES: # pragma: no cover - payload is tiny
|
|
return
|
|
version = _pack_version() or "0"
|
|
req = urllib.request.Request(
|
|
url.rstrip("/") + "/ingest",
|
|
data=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "{}/{}".format(_USER_AGENT, version),
|
|
},
|
|
method="POST",
|
|
)
|
|
urllib.request.urlopen(req, timeout=_TIMEOUT_SECONDS).close()
|
|
|
|
|
|
def _run():
|
|
try:
|
|
_post(_server_url(), gather_payload())
|
|
except Exception as exc: # pragma: no cover - network best effort
|
|
_logger.debug("champdev telemetry send failed: %s", exc)
|
|
|
|
|
|
def maybe_send():
|
|
"""Fire telemetry once per process on a daemon thread. Returns immediately."""
|
|
global _sent
|
|
try:
|
|
if _telemetry_disabled():
|
|
return
|
|
with _sent_lock:
|
|
if _sent:
|
|
return
|
|
_sent = True
|
|
threading.Thread(target=_run, name="champdev-telemetry", daemon=True).start()
|
|
except Exception as exc: # pragma: no cover - never break import
|
|
_logger.debug("champdev telemetry skipped: %s", exc)
|