Current Path :
/
opt
/
imunify360
/
venv
/
share
/
imunify360
/
scripts
/
Or
Select Your Path :
Upload File :
New :
File
Dir
//opt/imunify360/venv/share/imunify360/scripts/cluster_integration.py
#!/opt/imunify360/venv/bin/python3 """ Cluster integration script for Imunify360. Reads active applications from the NATS JetStream KV bucket that the im360-k8s-syncer publishes to, and renders the result in the integration.conf format the panel expects. This replaces the old sqlite-via-resident-agent path: the registration_grpc plugin and its active_applications table are gone; the KV bucket is the source of truth. The read is resilient to transient NATS glitches. nats-py's ``kv.keys()`` walks a watcher and can return a *short* list with no error raised when a key lands on the watcher's init boundary while the syncer is concurrently republishing every entry. A short list, taken as authoritative, has made the agent reject live apps ("not a registered cluster application") and strip their feature permissions. To prevent that: * every read is validated against the bucket's authoritative live-key count (``BucketStatus.values``) and re-listed before being trusted; * a confirmed-complete read is cached on local disk; * a read that cannot be confirmed complete (short count, failed key fetch, or unreachable broker) serves the last-good cached snapshot instead of a degraded list, and reports the degradation to Sentry. Usage: cluster_integration.py users - Returns list of users (app_ids) cluster_integration.py domains - Returns domain-to-owner mapping """ import asyncio import json import os import sys import tempfile import time from configparser import ConfigParser try: import nats from nats.js.errors import BucketNotFoundError, NoKeysError except ImportError: # pragma: no cover - runtime environment always has it nats = None BucketNotFoundError = Exception NoKeysError = Exception INTEGRATION_CONF = "/etc/sysconfig/imunify360/integration.conf" DEFAULT_BASEDIR = "/home" # NATS connection — same addr + token files the resident-agent writes # at startup, same convention the asyncclient NATSGatewayAPI uses. NATS_ADDR_PATH = os.getenv( "I360_NATS_ADDR_PATH", "/var/run/imunify360/nats.addr" ) NATS_TOKEN_PATH = os.getenv( "I360_NATS_TOKEN_PATH", "/var/run/imunify360/nats.token" ) NATS_DEFAULT_ADDR = os.getenv( "I360_NATS_DEFAULT_ADDR", f"127.0.0.1:{os.getenv('I360_NATS_PORT', '44222')}", ) REGISTRATION_BUCKET = os.getenv("I360_REGISTRATION_KV_BUCKET", "REGISTRATION") CONNECT_TIMEOUT = 5 # Local last-good snapshot. Lives on the persisted (PVC-backed) data dir, # NOT tmpfs, so it survives pod restarts and migrations — the no-cache # branch in _fetch_registrations then only fires on a genuine first run # before NATS has ever been reachable. Overridable for tests. CACHE_PATH = os.getenv( "I360_REGISTRATION_CACHE_PATH", "/var/imunify360/cluster_registration_cache.json", ) # How many times to re-list within a single connection before giving up on # a complete read and falling back to cache. The dominant failure is a # transient short kv.keys(); a re-list almost always returns the full set. LIST_ATTEMPTS = int(os.getenv("I360_REGISTRATION_LIST_ATTEMPTS", "3")) # Minimum seconds between Sentry reports — the panel polls this script, so # unthrottled degraded reports would spam a single Sentry issue. SENTRY_COOLDOWN = int(os.getenv("I360_REGISTRATION_SENTRY_COOLDOWN", "300")) class IncompleteReadError(Exception): """The live KV read could not be confirmed complete. Raised for an unreachable broker, a failed listing/fetch, or a key count short of the bucket's authoritative entry count. The caller serves the cached snapshot instead of trusting a degraded read. """ def get_basedir(): """Read basedir from integration.conf [malware] section.""" cfg = ConfigParser() cfg.read(INTEGRATION_CONF) return cfg.get("malware", "basedir", fallback=DEFAULT_BASEDIR) def _ok_response(data): """Wrap data with metadata expected by panel validation.""" return {"data": data, "metadata": {"result": "ok"}} def _error_response(message): """Return error response with metadata.""" return {"data": None, "metadata": {"result": "error", "message": message}} def _read_first_line(path): try: with open(path) as f: return f.read().strip() except OSError: return "" async def _close(nc): try: await nc.drain() except Exception: pass try: await nc.close() except Exception: pass async def _list_once(kv): """One listing pass, validated against the bucket's live-key count. The count cross-check (``BucketStatus.values``) is what catches a silently truncated ``kv.keys()``: the syncer bucket is History=1 with no deletes, so the stream message count equals the number of live keys. A failed get/parse or a count short of that authoritative number both raise ``IncompleteReadError`` rather than yield a partial list. (If the bucket config ever gained history>1 or delete tombstones, ``values`` would exceed the live-key count and we would fall back to cache more often — never publish a short list. The safe direction.) """ try: status = await kv.status() expected = status.values except Exception as exc: raise IncompleteReadError("status: %s" % exc) try: keys = await kv.keys() except NoKeysError: keys = [] except Exception as exc: raise IncompleteReadError("keys: %s" % exc) result = {} for key in keys: try: entry = await kv.get(key) payload = json.loads(entry.value) except Exception as exc: # A listed-but-unreadable key, or a corrupt value, means we # cannot represent this app — treat the snapshot as incomplete # rather than silently dropping the app from the list. raise IncompleteReadError("key %s: %s" % (key, exc)) app_id = payload.get("app_id") or key result[app_id] = {"domains": payload.get("domains") or {}} # Completeness is about how many live KEYS we fetched vs the bucket's # authoritative count — not how many distinct app_ids ended up in # ``result``. Keying ``result`` by app_id can collapse two keys into one # entry (e.g. a payload whose app_id duplicates another's), which would # make a full read look short and wedge us onto cache. Count keys. if len(keys) < expected: raise IncompleteReadError( "short read: %d of %d entries" % (len(keys), expected) ) return result async def _read_registrations_live(): """Return {app_id: {"domains": {...}}} from a confirmed-complete read. Raises ``IncompleteReadError`` when the snapshot cannot be confirmed complete, so the caller can fall back to the cached snapshot rather than publish a degraded list. """ if nats is None: raise IncompleteReadError("nats-py not installed") addr = _read_first_line(NATS_ADDR_PATH) or NATS_DEFAULT_ADDR token = _read_first_line(NATS_TOKEN_PATH) connect_kwargs = { "servers": [f"nats://{addr}"], "connect_timeout": CONNECT_TIMEOUT, "max_reconnect_attempts": 0, } if token: connect_kwargs["token"] = token # A connect failure fails fast to the cache — retrying a 5s connect # would just stall the agent's integration call during an outage. try: nc = await nats.connect(**connect_kwargs) except Exception as exc: raise IncompleteReadError("connect: %s" % exc) try: js = nc.jetstream() try: kv = await js.key_value(REGISTRATION_BUCKET) except BucketNotFoundError as exc: # A missing bucket is degraded, not a confirmed-empty read: with a # good cache we serve it (a transient bucket gap must not strip # live apps); on a cold cache the caller seeds empty so a genuine # first run before the bucket exists still proceeds. raise IncompleteReadError("bucket not found: %s" % exc) # Re-list on the same connection: a transient short kv.keys() is # the failure we are guarding against, and a fresh listing almost # always returns the full set. last_err = None for _ in range(LIST_ATTEMPTS): try: return await _list_once(kv) except IncompleteReadError as exc: last_err = exc raise last_err or IncompleteReadError("listing failed") finally: await _close(nc) def _load_cache(): try: with open(CACHE_PATH) as f: data = json.load(f) except (OSError, ValueError): return None if not isinstance(data, dict) or "registrations" not in data: return None return data def _atomic_write(path, payload): directory = os.path.dirname(path) or "." os.makedirs(directory, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=directory, prefix=".reg_cache.") try: with os.fdopen(fd, "w") as f: f.write(payload) os.replace(tmp, path) except Exception: try: os.unlink(tmp) except OSError: pass raise def _store_cache(registrations, last_sentry_at=None): """Persist a confirmed-complete snapshot as the new last-good cache. Resets the Sentry throttle: a fresh successful read ends the degraded episode, so the next degradation reports promptly. """ _atomic_write( CACHE_PATH, json.dumps( { "updated_at": time.time(), "last_sentry_at": last_sentry_at, "registrations": registrations, } ), ) def _touch_sentry_time(cache, now): """Bump the throttle timestamp without disturbing the cached snapshot.""" updated = dict(cache) updated["last_sentry_at"] = now _atomic_write(CACHE_PATH, json.dumps(updated)) def _report_sentry(message, level, fingerprint): """Best-effort Sentry report. Never let it break integration output.""" try: from defence360agent import sentry sentry.configure_sentry() sentry.log_message( message, level=level, fingerprint=fingerprint, component="cluster_integration", ) sentry.flush_sentry() except Exception: pass def _maybe_report_degraded(cache, reason): now = time.time() last = cache.get("last_sentry_at") or 0 if now - last < SENTRY_COOLDOWN: return try: _touch_sentry_time(cache, now) except OSError: # No persisted stamp means no cooldown to rate-limit on, so the report # would repeat on every poll. Skip it rather than flood Sentry. return age = now - (cache.get("updated_at") or now) _report_sentry( "cluster_integration: degraded NATS read (%s); served %d cached " "registrations (age %.0fs)" % (reason, len(cache.get("registrations") or {}), age), level="warning", fingerprint="cluster-integration-degraded-read", ) async def _fetch_registrations(): """Return ({app_id: {"domains": {...}}}, err). err == "" → authoritative result: either a confirmed-complete live read or, on a degraded read, the last-good cached snapshot. err != "" → no complete read and no cache to serve; the caller surfaces this as a hard error (non-zero exit). """ try: registrations = await _read_registrations_live() except IncompleteReadError as exc: cache = _load_cache() if cache is not None: _maybe_report_degraded(cache, str(exc)) return cache["registrations"], "" # No cache yet: this is a first run / migration before NATS is up. # The agent calls panel_users() during migration; erroring here # would break it. Seed an empty persisted cache and return empty # data (a valid "no apps yet" state) so migration proceeds. The # first successful read fills the cache in, and because it is # persisted we never take this branch again. try: # Seed the cache stamped as just-reported so the next poll takes # the throttled warm path instead of re-reporting. _store_cache({}, last_sentry_at=time.time()) _report_sentry( "cluster_integration: NATS unreachable on a cold cache; " "seeding empty registrations: %s" % exc, level="warning", fingerprint="cluster-integration-no-cache", ) except OSError: # Cannot persist the seed, so we cannot throttle either: skip the # report rather than flood Sentry on every panel poll. pass return {}, "" try: _store_cache(registrations) except OSError: # The live read is authoritative; a best-effort cache write failure # must not discard it and force callers to fall back. pass return registrations, "" def _fetch(): return asyncio.run(_fetch_registrations()) def get_users(): """ Return users in integration.conf format. Format: {"data": [{"username": "app_id", "home": "/home"}, ...], "metadata": {"result": "ok"}} """ apps, err = _fetch() if err: return _error_response(err) basedir = get_basedir() users = [ {"username": app_id, "home": basedir} for app_id in sorted(apps.keys()) ] return _ok_response(users) def get_domains(): """ Return domains in integration.conf format. Format: {"data": {"domain.com": {"owner": "app_id", "document_root": "/home"}, ...}, "metadata": {"result": "ok"}} """ apps, err = _fetch() if err: return _error_response(err) basedir = get_basedir() domains_data = {} for app_id, info in apps.items(): for domain in info.get("domains", {}): domains_data[domain] = { "owner": app_id, "document_root": basedir, } return _ok_response(domains_data) def main(): if len(sys.argv) < 2: print("Usage: cluster_integration.py [users|domains]", file=sys.stderr) sys.exit(1) command = sys.argv[1] if command == "users": result = get_users() elif command == "domains": result = get_domains() else: print(f"Unknown command: {command}", file=sys.stderr) sys.exit(1) print(json.dumps(result)) metadata = result.get("metadata", {}) if metadata.get("result") != "ok": # Defensive net. The fetch path no longer errors on an unreachable # broker — it serves cache or seeds empty (ok) so migration is not # broken. A non-ok result here therefore means something genuinely # malformed, which must be a hard failure rather than an exit-0 # body the agent could mistake for a benign empty result. The agent # runs us via check_run(): on a non-zero exit it raises # CheckRunError *before* parsing stdout and records the error from # our *stderr* (see generic/panel.py:_get_integration_data) — so the # detail has to go to stderr; stdout is never read once the exit # code is non-zero. print( metadata.get("message") or "integration script error", file=sys.stderr, ) sys.exit(1) if __name__ == "__main__": main()