"""Shared helpers for the Windows installer trio (Setup.exe / service host / Uninstall.exe). Kept dependency-light (stdlib + httpx only) so each frozen binary stays small and self-contained; the DPAPI credential write reuses the agent's `gti_fiscal_agent.credential_store` module. Policy enforced here (mirrors CLAUDE.md): - `agent.env` NEVER holds AGENT_TOKEN or any secret — only non-secret config. - `credentials.bin` is DPAPI LocalMachine-encrypted with a SYSTEM/Administrators-only ACL (fail closed if DPAPI/icacls is unavailable). - FIREBIRD_WRITES=0 and FISCAL_MODE=DRY_RUN are always written; no UI or flag ever enables writes or AI write. - The install code is sent once to the Hub and is never persisted locally. """ from __future__ import annotations import json import os import subprocess import sys import time import winreg from pathlib import Path import httpx PROD_HUB_URL = "https://gtifiscal.gtigrupo.com.br" SERVICE_NAME = "GTIFiscalAgent" SERVICE_DISPLAY = "GTI Fiscal Agent" SERVICE_FAILURE = "restart/60000/restart/60000/restart/60000" UNINSTALL_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\GTI Fiscal Agent" def install_dir() -> Path: override = os.environ.get("GTI_INSTALL_DIR") if override: return Path(override).resolve() return Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "GTI" / "FiscalAgent" def hub_url() -> str: return (os.environ.get("GTI_HUB_URL") or PROD_HUB_URL).rstrip("/") def machine_fingerprint(max_len: int = 128) -> str: try: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography") as key: value, _ = winreg.QueryValueEx(key, "MachineGuid") return str(value)[:max_len] except OSError: return os.environ.get("COMPUTERNAME", "")[:max_len] def enroll(install_code: str, *, hub: str | None = None) -> dict: """POST /v1/agents/enroll with the standalone install code. Returns the parsed JSON on success; raises httpx.HTTPStatusError with the Hub's reason in the response body otherwise.""" url = (hub or hub_url()).rstrip("/") + "/v1/agents/enroll" payload = { "install_code": install_code, "hostname": os.environ.get("COMPUTERNAME", ""), "agent_version": os.environ.get("AGENT_VERSION", ""), "machine_fingerprint": machine_fingerprint(), } response = httpx.post(url, json=payload, timeout=30) response.raise_for_status() return response.json() def write_agent_env(install_dir_path: Path, enroll_result: dict, firebird_path: str = "") -> None: """Write the non-secret agent.env. The permanent credential goes to credentials.bin (DPAPI), never here. FIREBIRD_WRITES=0 / DRY_RUN always.""" from gti_fiscal_agent.runner import REFERENCE_SCHEMA_V1 install = Path(install_dir_path) lines = [ (f"GTI_HUB_URL={enroll_result.get('hub_url') or hub_url()}"), (f"AGENT_ID={enroll_result.get('agent_id') or ''}"), (f"INSTALLATION_ID={enroll_result.get('installation_id') or ''}"), ("COMPANY_ID="), ("COMPANY_UF="), (f"FIREBIRD_DATABASE_PATH={firebird_path}"), # Firebird user/password live ONLY in the DPAPI-protected firebird.bin (never here, never sent to the Hub). ("FB_USER="), ("FB_PASSWORD="), ("FISCAL_MODE=DRY_RUN"), ("FIREBIRD_WRITES=0"), (f"AGENT_VERSION={os.environ.get('AGENT_VERSION', '')}"), ("POLL_INTERVAL_SECONDS=15"), (f"AGENT_LOG_PATH={install / 'logs' / 'agent.log'}"), (f"AGENT_STATE_DB={install / 'agent_state.sqlite'}"), (f"EXPECTED_SCHEMA_FINGERPRINT={REFERENCE_SCHEMA_V1}"), (f"GTI_WRITE_EXECUTOR_PATH={install / 'scripts' / 'firebird_write_executor.py'}"), (f"AGENT_HOSTNAME={os.environ.get('COMPUTERNAME', '')}"), ("AUTHORIZED_WRITE_DATABASE_PATH="), ] install.mkdir(parents=True, exist_ok=True) (install / "agent.env").write_text("\n".join(lines) + "\n", encoding="utf-8") def write_credential(install_dir_path: Path, agent_token: str) -> None: """Write credentials.bin via DPAPI LocalMachine + ACL (fail closed).""" from gti_fiscal_agent.credential_store import write_credential as _write install = Path(install_dir_path) install.mkdir(parents=True, exist_ok=True) _write(install / "credentials.bin", agent_token) def write_firebird_credential(install_dir_path: Path, user: str, password: str) -> None: """Firebird user/password -> firebird.bin via DPAPI LocalMachine + ACL (fail closed). Local only: this never reaches the Hub and never lands in agent.env.""" from gti_fiscal_agent.credential_store import write_credential as _write install = Path(install_dir_path) install.mkdir(parents=True, exist_ok=True) _write(install / "firebird.bin", json.dumps({"user": user, "password": password})) def detect_existing(install_dir_path: Path) -> dict | None: """An already-enrolled installation (identity + DPAPI credential present).""" install = Path(install_dir_path) env_file, cred = install / "agent.env", install / "credentials.bin" if not env_file.exists() or not cred.exists(): return None values = {} for line in env_file.read_text(encoding="utf-8", errors="replace").splitlines(): if "=" in line and not line.lstrip().startswith("#"): key, _, value = line.partition("=") values[key.strip()] = value.strip() if not values.get("AGENT_ID"): return None return {"agent_id": values["AGENT_ID"], "installation_id": values.get("INSTALLATION_ID"), "hub_url": values.get("GTI_HUB_URL") or hub_url(), "env": values} def upgrade_agent_env(install_dir_path: Path, agent_version: str) -> None: """Migrate an existing agent.env to the central-control model: drop the legacy local AGENT_JOBS_ENABLED toggle, refresh AGENT_VERSION, keep every other value (identity, Firebird path...).""" env_file = Path(install_dir_path) / "agent.env" kept, seen_version = [], False for line in env_file.read_text(encoding="utf-8", errors="replace").splitlines(): key = line.partition("=")[0].strip() if key == "AGENT_JOBS_ENABLED": continue if key == "AGENT_VERSION": line, seen_version = f"AGENT_VERSION={agent_version}", True kept.append(line) if not seen_version: kept.append(f"AGENT_VERSION={agent_version}") env_file.write_text(chr(10).join(kept) + chr(10), encoding="utf-8") def wait_service_state(wanted: str, timeout: float = 45.0) -> bool: deadline = time.time() + timeout while time.time() < deadline: if wanted in _sc(["query", SERVICE_NAME]).stdout: return True time.sleep(1.5) return False def wait_for_heartbeat(agent_token: str, hub: str, timeout: float = 120.0) -> bool: """True once the Hub sees this Agent ONLINE (first heartbeat after the service started).""" deadline = time.time() + timeout while time.time() < deadline: try: response = httpx.get((hub or hub_url()).rstrip("/") + "/v1/agents/me", headers={"Authorization": f"Bearer {agent_token}"}, timeout=10) if response.status_code == 200 and response.json().get("status") == "ONLINE": return True except Exception: # noqa: BLE001 pass time.sleep(4) return False def log_path() -> Path: return Path(os.environ.get("GTI_LOG_DIR") or os.path.join(os.environ.get("PROGRAMDATA", r"C:\ProgramData"), "GTI", "FiscalAgent")) / "installer.log" def ilog(message: str) -> None: """Append one timestamped line to installer.log (best effort, never raises, never holds a secret).""" try: path = log_path() path.parent.mkdir(parents=True, exist_ok=True) with path.open("a", encoding="utf-8") as fh: fh.write(time.strftime("%Y-%m-%d %H:%M:%S ") + " ".join(message.split()) + chr(10)) except Exception: # noqa: BLE001 pass def _sc(args: list[str]) -> subprocess.CompletedProcess[str]: result = subprocess.run(["sc.exe", *args], capture_output=True, text=True) text = " ".join((result.stdout + " " + result.stderr).split()) ilog(f"sc {' '.join(a for a in args if not a.lower().startswith('binpath'))} -> rc={result.returncode} {text[:300]}") return result def install_service(install_dir_path: Path) -> None: """Register the service (Delayed Auto) and set its failure-recovery policy. The frozen onedir host lives in /GTIFiscalAgentService/ ; SCM's binPath points at that executable (its `_internal/` dir travels with it).""" exe = Path(install_dir_path) / "GTIFiscalAgentService" / "GTIFiscalAgentService.exe" bin_path = f"\"{exe}\"" result = _sc(["create", SERVICE_NAME, "binPath=", bin_path, "start=", "auto", "DisplayName=", SERVICE_DISPLAY]) # 1073 = ERROR_SERVICE_EXISTS. Match the code, never the message: the text is localized (pt-BR: "já existe"). if result.returncode not in (0, 1073) and "already exists" not in (result.stdout + result.stderr): raise RuntimeError(f"sc create failed: {result.stdout} {result.stderr}".strip()) _sc(["config", SERVICE_NAME, "start=", "delayed-auto"]) _sc(["config", SERVICE_NAME, "obj=", "LocalSystem"]) failure = _sc(["failure", SERVICE_NAME, "reset=", "86400", "actions=", SERVICE_FAILURE]) if failure.returncode != 0: raise RuntimeError(f"sc failure failed: {failure.stdout} {failure.stderr}".strip()) config = _sc(["qc", SERVICE_NAME]) if "DELAYED" not in (config.stdout + config.stderr).upper() and config.returncode == 0: raise RuntimeError("sc config: delayed-auto did not stick") def service_installed() -> bool: result = _sc(["query", SERVICE_NAME]) return result.returncode == 0 and ("SERVICE_NAME" in result.stdout or "RUNNING" in result.stdout or "STOPPED" in result.stdout) def service_running() -> bool: result = _sc(["query", SERVICE_NAME]) return "RUNNING" in result.stdout def start_service() -> None: _sc(["start", SERVICE_NAME]) def service_state() -> str: out = subprocess.run(["sc.exe", "query", SERVICE_NAME], capture_output=True, text=True).stdout for name in ("RUNNING", "START_PENDING", "STOP_PENDING", "STOPPED", "PAUSED"): if name in out: return name return "UNKNOWN" def service_process_alive() -> bool: out = subprocess.run(["sc.exe", "queryex", SERVICE_NAME], capture_output=True, text=True).stdout for line in out.splitlines(): if line.strip().upper().startswith("PID"): pid = line.split(":", 1)[1].strip() if pid.isdigit() and int(pid) > 0: tl = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH"], capture_output=True, text=True).stdout return pid in tl return False def start_and_verify(attempts: int = 4, per_attempt: float = 60.0) -> tuple[bool, str]: """Start the service and prove it: RUNNING for a few seconds AND a live host process. The first start after fresh files often exceeds the SCM's 30 s limit (error 1053: antivirus scans the new onedir), so a failed attempt is diagnosed into installer.log and retried; the second start is fast.""" detail = "not started" for attempt in range(1, attempts + 1): ilog(f"start attempt {attempt}/{attempts}; state={service_state()}") if service_state() != "RUNNING": _sc(["start", SERVICE_NAME]) deadline = time.time() + per_attempt while time.time() < deadline and service_state() in ("START_PENDING", "STOP_PENDING", "UNKNOWN"): time.sleep(1.5) if service_state() == "RUNNING": time.sleep(6) # must survive its own startup (preflight) rather than crash right after RUNNING if service_state() == "RUNNING" and service_process_alive(): ilog("service RUNNING and host process alive") return True, "RUNNING" state = service_state() query = subprocess.run(["sc.exe", "query", SERVICE_NAME], capture_output=True, text=True).stdout exit_codes = " ".join(line.strip() for line in query.splitlines() if "EXIT_CODE" in line.upper()) detail = f"attempt {attempt}: state={state} {exit_codes}".strip() ilog(detail) try: tail = (install_dir() / "logs" / "agent.log").read_text(encoding="utf-8", errors="replace").splitlines()[-3:] ilog("agent.log tail: " + " | ".join(tail)[:400]) except Exception: # noqa: BLE001 pass time.sleep(5) ilog(f"service did NOT start: {detail}") return False, detail def stop_service() -> None: _sc(["stop", SERVICE_NAME]) def ensure_service_stopped(timeout: float = 45.0) -> None: """Stop the service and make sure its host process is really gone (an upgrade must be able to overwrite the executable): SCM stop, wait, then kill any leftover host process.""" stop_service() if not wait_service_state("STOPPED", timeout=timeout): subprocess.run(["taskkill", "/F", "/IM", "GTIFiscalAgentService.exe"], capture_output=True, text=True) wait_service_state("STOPPED", timeout=15) time.sleep(2) def remove_service() -> None: """Stop then delete the service; both best effort (idempotent).""" stop_service() _sc(["delete", SERVICE_NAME]) def write_arp_uninstall(install_dir_path: Path) -> None: """Write the Add/Remove Programs entry pointing to Uninstall.exe.""" install = Path(install_dir_path) with winreg.CreateKeyEx(winreg.HKEY_LOCAL_MACHINE, UNINSTALL_KEY, 0, winreg.KEY_WRITE) as key: winreg.SetValueEx(key, "DisplayName", 0, winreg.REG_SZ, SERVICE_DISPLAY) winreg.SetValueEx(key, "DisplayVersion", 0, winreg.REG_SZ, os.environ.get("AGENT_VERSION", "")) winreg.SetValueEx(key, "Publisher", 0, winreg.REG_SZ, "GTI Sistemas") winreg.SetValueEx(key, "InstallLocation", 0, winreg.REG_SZ, str(install)) winreg.SetValueEx(key, "UninstallString", 0, winreg.REG_SZ, str(install / "Uninstall.exe")) winreg.SetValueEx(key, "DisplayIcon", 0, winreg.REG_SZ, str(install / "GTIFiscalAgentService" / "GTIFiscalAgentService.exe")) winreg.SetValueEx(key, "NoModify", 0, winreg.REG_DWORD, 1) winreg.SetValueEx(key, "NoRepair", 0, winreg.REG_DWORD, 1) def remove_arp_entry() -> None: try: winreg.DeleteKey(winreg.HKEY_LOCAL_MACHINE, UNINSTALL_KEY) except OSError: pass def audit_event(agent_token: str, hub: str, event_type: str, message: str) -> None: """Best-effort authenticated event POST (WINDOWS_INSTALL_COMPLETED etc.). Never sends secrets; ignores failures so a hub hiccup cannot fail an install.""" try: response = httpx.post( (hub or hub_url()).rstrip("/") + "/v1/agents/events", json={"event_type": event_type, "message": message, "context": {"source": "setup.exe", "agent_version": os.environ.get("AGENT_VERSION", "")}}, headers={"Authorization": f"Bearer {agent_token}"}, timeout=15, ) response.raise_for_status() except Exception: # noqa: BLE001 — best effort only return