#!/usr/bin/env python3 """AgentCheckin shared utilities: paths, HTTP, atomic JSON storage, logging. All persistent data lives under TRIM_PKGVAR (/vol{n}/@appdata/AgentCheckin). Falls back to a local ./data directory when running outside fnOS (dev/test). """ import json import os import sys import time import fcntl import urllib.request import urllib.error import ssl APP_NAME = "AgentCheckin" # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- APP_NAME = "AgentCheckin" def _volume_root(): """Volume root of the install layout. @appcenter layout: /vol{n}/@appcenter/{app}/backend -> /vol{n} (three levels up from the backend directory) """ p = os.path.dirname(os.path.abspath(__file__)) for _ in range(3): p = os.path.dirname(p) return p def _candidate_dirs(): """All plausible persistent-data locations across fnOS install layouts.""" here = os.path.dirname(os.path.abspath(__file__)) # .../{app}/backend vol = _volume_root() cands = [ # documented layout: /var/apps/{app}/var -> /vol{n}/@appdata/{app} "/var/apps/%s/var" % APP_NAME, # @appcenter layout: {volume}/@appcenter/{app} -> {volume}/@appdata/{app} os.path.join(vol, "@appdata", APP_NAME), # well-known volume data dirs (in case the app dir layout differs) "/vol1/@appdata/%s" % APP_NAME, "/vol2/@appdata/%s" % APP_NAME, # relative fallbacks for manual unpacking os.path.join(here, "..", "..", "var"), os.path.join(here, "..", "var"), ] return [os.path.abspath(c) for c in cands] def _data_dir(): # 1. explicit override (set by cmd/main and index.cgi so the daemon and # the CGI always agree on one location) explicit = os.environ.get("AGENTCHECKIN_DATA_DIR", "") if explicit: return os.path.abspath(explicit) if os.environ.get("AGENTCHECKIN_DEV"): base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "devdata") return os.path.abspath(base) # 2. fnOS lifecycle env (set for cmd/main; often absent for CGI processes) env = os.environ.get("TRIM_PKGVAR", "") if env and os.path.isdir(env): return env # 3. first existing candidate for cand in _candidate_dirs(): if os.path.isdir(cand): return cand # 4. nothing exists yet: use the @appdata convention on the same volume return os.path.abspath(os.path.join(_volume_root(), "@appdata", APP_NAME)) DATA_DIR = _data_dir() ACCOUNTS_FILE = os.path.join(DATA_DIR, "accounts.json") SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json") STATE_FILE = os.path.join(DATA_DIR, "state.json") TRIGGER_FILE = os.path.join(DATA_DIR, "trigger") PID_FILE = os.path.join(DATA_DIR, "daemon.pid") LOG_DIR = os.path.join(DATA_DIR, "logs") LOG_FILE = os.path.join(LOG_DIR, "checkin.log") USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") _SSL_CTX = ssl.create_default_context() _SSL_CTX.check_hostname = False _SSL_CTX.verify_mode = ssl.CERT_NONE def ensure_dirs(): os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(LOG_DIR, exist_ok=True) # --------------------------------------------------------------------------- # Time helpers # --------------------------------------------------------------------------- def now_ts(): return int(time.time()) def today_str(): return time.strftime("%Y-%m-%d", time.localtime()) def fmt_ts(ts): if not ts: return "" return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) def fmt_now(): return fmt_ts(now_ts()) # --------------------------------------------------------------------------- # Logging (append to checkin.log; never log tokens) # --------------------------------------------------------------------------- def log(msg, level="INFO"): line = "[%s] [%s] %s\n" % (fmt_now(), level, msg) try: ensure_dirs() # simple rotation: keep one previous generation, cap at ~2 MiB try: if os.path.exists(LOG_FILE) and os.path.getsize(LOG_FILE) > 2 * 1024 * 1024: os.replace(LOG_FILE, LOG_FILE + ".1") except OSError: pass with open(LOG_FILE, "a", encoding="utf-8") as f: f.write(line) except OSError: pass return line # --------------------------------------------------------------------------- # HTTP # --------------------------------------------------------------------------- def http_post_json(url, body=None, headers=None, timeout=20): """POST JSON. Returns (data, http_status, error). On success data is the parsed JSON (envelope or raw). On failure data is None and error describes the problem. HTTP error bodies that carry a {code,msg} envelope are surfaced through error as 'code=.. msg=..'. """ payload = json.dumps(body if body is not None else {}).encode("utf-8") req = urllib.request.Request(url, data=payload, method="POST") req.add_header("Content-Type", "application/json") req.add_header("Accept", "application/json") req.add_header("User-Agent", USER_AGENT) if headers: for k, v in headers.items(): req.add_header(k, v) try: with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp: status = resp.status raw = resp.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as e: raw = "" try: raw = e.read().decode("utf-8", errors="replace") except Exception: pass try: env = json.loads(raw) if isinstance(env, dict) and env.get("code", 0) != 0: return None, e.code, "code=%s msg=%s" % (env.get("code"), env.get("msg", "")) if isinstance(env, dict): return env, e.code, None except (ValueError, json.JSONDecodeError): pass return None, e.code, "HTTP %s: %s" % (e.code, raw[:200]) except urllib.error.URLError as e: return None, 0, "network error: %s" % (getattr(e, "reason", e)) except Exception as e: # timeout, reset, ... return None, 0, "request failed: %s" % e try: env = json.loads(raw) except (ValueError, json.JSONDecodeError): return None, status, "non-JSON response: %s" % raw[:200] return env, status, None def http_get(url, headers=None, timeout=20): """GET. Returns (parsed_json_or_None, http_status, error).""" req = urllib.request.Request(url, method="GET") req.add_header("Accept", "application/json") req.add_header("User-Agent", USER_AGENT) if headers: for k, v in headers.items(): req.add_header(k, v) try: with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp: status = resp.status raw = resp.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as e: raw = "" try: raw = e.read().decode("utf-8", errors="replace") except Exception: pass try: env = json.loads(raw) if isinstance(env, dict): return env, e.code, None except (ValueError, json.JSONDecodeError): pass return None, e.code, "HTTP %s: %s" % (e.code, raw[:200]) except urllib.error.URLError as e: return None, 0, "network error: %s" % (getattr(e, "reason", e)) except Exception as e: return None, 0, "request failed: %s" % e try: return json.loads(raw), status, None except (ValueError, json.JSONDecodeError): return None, status, "non-JSON response: %s" % raw[:200] # --------------------------------------------------------------------------- # Atomic JSON storage with flock # --------------------------------------------------------------------------- def _locked(path, mode="r"): """Open path with an exclusive flock held for the lifetime of the handle. Returns (fileobj, lock_path). Caller must close the fileobj to release. """ lock_path = path + ".lock" lf = open(lock_path, "a+") fcntl.flock(lf.fileno(), fcntl.LOCK_EX) if mode == "r" and not os.path.exists(path): return None, lf f = open(path, mode, encoding="utf-8") return f, lf def read_json(path, default): try: f, lf = _locked(path, "r") except OSError: return default try: if f is None: return default try: return json.load(f) except (ValueError, json.JSONDecodeError): return default finally: if f is not None: f.close() lf.close() def write_json(path, obj, private=True): ensure_dirs() tmp = "%s.tmp.%d" % (path, os.getpid()) data = json.dumps(obj, ensure_ascii=False, indent=2) f, lf = _locked(path, "a") try: with open(tmp, "w", encoding="utf-8") as t: t.write(data) t.flush() os.fsync(t.fileno()) os.replace(tmp, path) if private: try: os.chmod(path, 0o600) except OSError: pass finally: try: os.unlink(tmp) except OSError: pass f.close() lf.close() # --------------------------------------------------------------------------- # Settings / accounts / state accessors # --------------------------------------------------------------------------- DEFAULT_SETTINGS = { "schedule_times": ["00:05", "09:00", "12:30", "19:00"], "webhook_url": "", "webhook_type": "auto", # auto | wecom | dingtalk | feishu | bark | generic "notify_on": ["fail"], # subset of ["success", "fail"] } def load_settings(): s = read_json(SETTINGS_FILE, None) if not isinstance(s, dict): s = {} out = dict(DEFAULT_SETTINGS) out.update({k: v for k, v in s.items() if k in DEFAULT_SETTINGS}) times = [t for t in out.get("schedule_times", []) if isinstance(t, str) and len(t) == 5 and t[2] == ":"] out["schedule_times"] = times or list(DEFAULT_SETTINGS["schedule_times"]) notify = [n for n in out.get("notify_on", []) if n in ("success", "fail")] out["notify_on"] = notify return out def save_settings(settings): write_json(SETTINGS_FILE, settings) def load_accounts(): data = read_json(ACCOUNTS_FILE, {"accounts": []}) if isinstance(data, dict) and isinstance(data.get("accounts"), list): return data["accounts"] if isinstance(data, list): return data return [] def save_accounts(accounts): write_json(ACCOUNTS_FILE, {"accounts": accounts}) def load_state(): s = read_json(STATE_FILE, {}) return s if isinstance(s, dict) else {} def save_state(state): write_json(STATE_FILE, state, private=False) def mask_token(tok): if not tok or not isinstance(tok, str): return "" if len(tok) <= 10: return "***" return tok[:6] + "***" + tok[-4:] def public_account(acc): """Account view for the UI: never expose raw tokens.""" a = dict(acc) auth = dict(acc.get("auth", {})) if "accessToken" in auth: auth["accessToken"] = mask_token(auth.get("accessToken")) if "refreshToken" in auth: auth["refreshToken"] = mask_token(auth.get("refreshToken")) a["auth"] = auth return a if __name__ == "__main__": # tiny self-check helper print("data dir:", DATA_DIR) print("exists:", os.path.isdir(DATA_DIR))