"""Setup.exe — universal, zero-touch Windows installer for the GTI Fiscal Agent. The operator runs it ONCE. Nothing has to be edited, started or scheduled afterwards. Flow (GUI wizard, or `--cli`): 1. discover the SGV Firebird database (read-only): exactly one compatible database is selected automatically; several/none/unknown credentials -> the wizard asks the operator DURING the installation (never silently guessed) 2. POST /v1/agents/enroll {install_code, hostname, agent_version, machine_fingerprint} 3. agent.env (non-secret only) + credentials.bin (Hub token) + firebird.bin (Firebird user/password) — DPAPI LocalMachine + SYSTEM/Administrators ACL 4. extract the service host + Uninstall.exe + write executor 5. register the service (Automatic Delayed Start + restart on failure) and START it 6. wait for the first heartbeat; the Hub then creates the INITIAL_SYNC by itself Re-running Setup on an already-enrolled host (no code needed) UPGRADES it in place: same identity and credentials, legacy local toggles removed, service restarted. The install code is never persisted; the Firebird password never goes to the Hub; no UI or flag enables Firebird/AI writes (always dry-run, writes disabled). """ from __future__ import annotations import argparse import os import shutil import sys import threading import time import tkinter as tk from pathlib import Path from tkinter import filedialog, messagebox, ttk from typing import Callable import httpx import firebird_discovery as fbd import install_common as common MEIPASS = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) Chooser = Callable[["fbd.Discovery"], "dict | None"] def setup_version() -> str: for candidate in (MEIPASS / "VERSION", Path(__file__).resolve().parents[1] / "release" / "VERSION"): if candidate.exists(): return candidate.read_text(encoding="utf-8").strip() return os.environ.get("AGENT_VERSION", "") def resolve_firebird(*, explicit: dict | None, chooser: Chooser | None, progress: Callable[[str], None] = lambda _m: None) -> dict: """Return {"path","user","password"} for a verified SGV database, or raise RuntimeError.""" if explicit: checked = fbd.probe(explicit["path"], explicit["user"], explicit["password"]) if checked.status != "OK": raise RuntimeError(f"banco Firebird informado não é um banco SGV utilizável ({checked.status}: {checked.detail})") return explicit progress("Procurando o banco de dados do SGV (Firebird)…") found = fbd.discover() if found.state == "SELECTED": chosen = found.selected return {"path": chosen.path, "user": chosen.user, "password": chosen.password} if chooser is None: detail = {"AMBIGUOUS": f"{len(found.compatible)} bancos SGV encontrados", "AUTH_REQUIRED": "usuário/senha do Firebird necessários", "NONE": "nenhum banco SGV encontrado"}[found.state] raise RuntimeError(f"seleção do banco Firebird necessária ({detail}); informe --fdb, --fb-user e --fb-password") choice = chooser(found) if not choice: raise RuntimeError("instalação cancelada: nenhum banco Firebird selecionado") checked = fbd.probe(choice["path"], choice["user"], choice["password"]) if checked.status != "OK": raise RuntimeError(f"banco selecionado não é utilizável ({checked.status}: {checked.detail})") return choice def install_flow(code: str, *, hub: str | None = None, install_dir_path: str | None = None, register_service: bool = True, firebird: dict | None = None, chooser: Chooser | None = None, require_firebird: bool = True, wait_heartbeat: bool = True, progress: Callable[[str], None] = lambda _m: None) -> dict: """Perform the full zero-touch install. Returns a result dict; raises RuntimeError with a user-facing message on any failure (nothing is enrolled if Firebird selection fails).""" if install_dir_path: os.environ["GTI_INSTALL_DIR"] = install_dir_path install = common.install_dir() chosen = resolve_firebird(explicit=firebird, chooser=chooser, progress=progress) if require_firebird else None progress("Enviando o código de instalação ao hub…") try: result = common.enroll(code, hub=hub) except httpx.HTTPStatusError as error: detail = "" try: detail = error.response.json().get("detail", "") except Exception: # noqa: BLE001 detail = "" reason = detail or error.response.reason_phrase or f"HTTP {error.response.status_code}" raise RuntimeError(f"enrollment rejeitado: {reason}") from error except Exception as error: # noqa: BLE001 raise RuntimeError(f"falha no enrollment: {error}") from error agent_version = str(result.get("agent_version") or os.environ.get("AGENT_VERSION") or setup_version()) os.environ["AGENT_VERSION"] = agent_version common.write_agent_env(install, result, firebird_path=chosen["path"] if chosen else "") common.write_credential(install, result["agent_token"]) if chosen: common.write_firebird_credential(install, chosen["user"], chosen["password"]) progress("Instalando o serviço…") extract_payload(install) started = heartbeat = False service_detail = "" if register_service: common.install_service(install) common.write_arp_uninstall(install) if chosen: # without a Firebird database the service would only fail preflight in a loop progress("Iniciando o serviço…") started, service_detail = common.start_and_verify() if started and wait_heartbeat: progress("Aguardando o primeiro heartbeat…") heartbeat = common.wait_for_heartbeat(result["agent_token"], result.get("hub_url") or hub, timeout=120) try: common.audit_event(result["agent_token"], result.get("hub_url") or hub, "WINDOWS_INSTALL_COMPLETED", f"agent {result.get('agent_id')} installed; service_running={started} heartbeat={heartbeat}") except Exception: # noqa: BLE001 — best effort pass result.update(service_detail=service_detail, install_dir=str(install), agent_version=agent_version, firebird_path=chosen["path"] if chosen else None, service_running=started, heartbeat=heartbeat, mode="INSTALL") return result def upgrade_flow(*, install_dir_path: str | None = None, progress: Callable[[str], None] = lambda _m: None) -> dict: """Re-run on an enrolled host: replace the binaries in place, keep identity/credentials, migrate agent.env to the central-control model, restart the service.""" if install_dir_path: os.environ["GTI_INSTALL_DIR"] = install_dir_path install = common.install_dir() existing = common.detect_existing(install) if existing is None: raise RuntimeError("nenhuma instalação existente encontrada para atualizar") version = setup_version() progress("Atualizando o Agent (identidade e credenciais preservadas)…") if common.service_installed(): common.ensure_service_stopped() extract_payload(install) common.upgrade_agent_env(install, version) common.install_service(install) common.write_arp_uninstall(install) os.environ["AGENT_VERSION"] = version progress("Iniciando o serviço…") common.ilog(f"upgrade to {version} from {existing.get('agent_id')}") started, service_detail = common.start_and_verify() heartbeat = False try: from gti_fiscal_agent.credential_store import read_credential heartbeat = started and common.wait_for_heartbeat(read_credential(install / "credentials.bin"), existing["hub_url"], timeout=120) except Exception: # noqa: BLE001 pass return {"agent_id": existing["agent_id"], "installation_id": existing["installation_id"], "install_dir": str(install), "agent_version": version, "service_running": started, "service_detail": service_detail, "heartbeat": heartbeat, "mode": "UPGRADE", "hub_url": existing["hub_url"]} def extract_payload(install: Path) -> None: """Copy the bundled service host onedir, Uninstall.exe and the write executor out of the onefile extraction dir into the install directory.""" install.mkdir(parents=True, exist_ok=True) service_dir = MEIPASS / "GTIFiscalAgentService" targets = [ (service_dir, install / "GTIFiscalAgentService"), (MEIPASS / "Uninstall.exe", install / "Uninstall.exe"), (MEIPASS / "scripts" / "firebird_write_executor.py", install / "scripts" / "firebird_write_executor.py"), ] for source, dest in targets: if not source.exists(): raise RuntimeError(f"bundled payload missing: {source}") if source.is_dir(): for attempt in range(5): # a just-stopped host can still hold files for a moment try: shutil.copytree(source, dest, dirs_exist_ok=True) break except PermissionError: if attempt == 4: raise time.sleep(3) else: dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, dest) def final_message(result: dict) -> str: if result.get("service_running") and result.get("heartbeat"): return ("Instalação concluída.\n\nO serviço GTI Fiscal Agent está em execução e já se comunicou com o painel. " "A primeira sincronização começa automaticamente.\n\nNenhuma outra ação é necessária neste servidor.") if result.get("service_running"): return ("Instalação concluída e serviço em execução, mas o primeiro heartbeat ainda não foi confirmado.\n\n" "O serviço continua tentando sozinho; acompanhe pelo painel. Nenhuma edição de arquivo é necessária.") return ("ATENÇÃO: o serviço GTI Fiscal Agent NÃO iniciou.\n\n" f"Motivo: {result.get('service_detail') or 'desconhecido'}\n" f"Detalhes em {common.log_path()} e {result.get('install_dir')}\\logs\\agent.log.") class SetupWizard: def __init__(self, root: tk.Tk): self.root = root root.title("GTI Fiscal Agent") root.resizable(False, False) root.geometry("720x300") self.existing = common.detect_existing(common.install_dir()) frame = ttk.Frame(root, padding=20) frame.pack(fill="both", expand=True) ttk.Label(frame, text="GTI Fiscal Agent — Instalação", font=("Segoe UI", 13, "bold")).pack(anchor="w") self.code_var = tk.StringVar() if self.existing: ttk.Label(frame, text="Este servidor já está instalado. Clique em Atualizar para aplicar a versão mais recente\n(identidade e credenciais são preservadas).").pack(anchor="w", pady=(6, 2)) else: ttk.Label(frame, text="Digite o código de instalação gerado no painel GTI Fiscal.").pack(anchor="w", pady=(4, 2)) ttk.Label(frame, text=f"Hub: {common.hub_url()}", foreground="#555").pack(anchor="w") ttk.Entry(frame, textvariable=self.code_var, width=26, font=("Consolas", 14), justify="center").pack(anchor="w", pady=(14, 4)) self.status = ttk.Label(frame, text="", foreground="#333", wraplength=660, justify="left") self.status.pack(anchor="w", pady=(6, 0)) buttons = ttk.Frame(frame) buttons.pack(anchor="e", pady=(10, 0)) self.install_btn = ttk.Button(buttons, text="Atualizar" if self.existing else "Instalar", command=self.on_install) self.install_btn.pack(side="left", padx=(0, 6)) ttk.Button(buttons, text="Fechar", command=root.destroy).pack(side="left") root.bind("", lambda _event: self.on_install()) def _say(self, text: str, color: str = "#333"): self.root.after(0, lambda: self.status.config(text=text, foreground=color)) def _busy(self, busy: bool): self.root.after(0, lambda: self.install_btn.config(state="disabled" if busy else "normal")) def on_install(self): code = self.code_var.get().strip() if not self.existing and not code: self.status.config(text="Informe o código de instalação.", foreground="#b00") return self._busy(True) threading.Thread(target=self._run, args=(code,), daemon=True).start() def choose_firebird(self, found: "fbd.Discovery") -> dict | None: """Runs on the worker thread; shows the selection dialog on the UI thread and waits.""" holder: dict = {} done = threading.Event() self.root.after(0, lambda: self._firebird_dialog(found, holder, done)) done.wait() return holder.get("choice") def _firebird_dialog(self, found: "fbd.Discovery", holder: dict, done: threading.Event): win = tk.Toplevel(self.root) win.title("Banco de dados do SGV") win.geometry("640x330") win.grab_set() frame = ttk.Frame(win, padding=16) frame.pack(fill="both", expand=True) path_var, user_var, pass_var = tk.StringVar(), tk.StringVar(value="SYSDBA"), tk.StringVar() msg = ttk.Label(frame, text="", foreground="#b00", wraplength=600) if found.state == "AMBIGUOUS": ttk.Label(frame, text="Foram encontrados vários bancos SGV. Selecione o correto:").pack(anchor="w") box = tk.Listbox(frame, height=6) for cand in found.compatible: box.insert("end", cand.path) box.pack(fill="x", pady=8) box.selection_set(0) def confirm(): if not box.curselection(): return cand = found.compatible[box.curselection()[0]] holder["choice"] = {"path": cand.path, "user": cand.user, "password": cand.password} win.destroy() done.set() else: why = "Usuário/senha do Firebird necessários." if found.state == "AUTH_REQUIRED" else "Nenhum banco SGV foi encontrado automaticamente." ttk.Label(frame, text=f"{why} Informe o arquivo do banco e as credenciais:").pack(anchor="w") row = ttk.Frame(frame) row.pack(fill="x", pady=6) ttk.Entry(row, textvariable=path_var, width=60).pack(side="left") ttk.Button(row, text="Procurar…", command=lambda: path_var.set(filedialog.askopenfilename(filetypes=[("Firebird", "*.fdb *.FDB"), ("Todos", "*.*")]) or path_var.get())).pack(side="left", padx=6) for label, var, secret in (("Usuário", user_var, False), ("Senha", pass_var, True)): r = ttk.Frame(frame) r.pack(fill="x", pady=2) ttk.Label(r, text=label, width=9).pack(side="left") ttk.Entry(r, textvariable=var, width=30, show="*" if secret else "").pack(side="left") if found.auth_failed: path_var.set(found.auth_failed[0].path) def confirm(): cand = fbd.probe(path_var.get().strip(), user_var.get().strip(), pass_var.get()) if cand.status != "OK": msg.config(text=f"Não foi possível usar este banco ({cand.status}: {cand.detail}). Verifique o arquivo e as credenciais.") return holder["choice"] = {"path": path_var.get().strip(), "user": user_var.get().strip(), "password": pass_var.get()} win.destroy() done.set() def cancel(): win.destroy() done.set() msg.pack(anchor="w") btns = ttk.Frame(frame) btns.pack(anchor="e", pady=10) ttk.Button(btns, text="Continuar", command=confirm).pack(side="left", padx=6) ttk.Button(btns, text="Cancelar", command=cancel).pack(side="left") win.protocol("WM_DELETE_WINDOW", cancel) def _run(self, code: str): try: if self.existing: result = upgrade_flow(progress=self._say) else: result = install_flow(code, chooser=self.choose_firebird, progress=self._say) except RuntimeError as error: self._busy(False) self._say(str(error), "#b00") self.root.after(0, lambda: messagebox.showerror("GTI Fiscal Agent", str(error))) return self._busy(False) self.root.after(0, lambda: (messagebox.showinfo("GTI Fiscal Agent", final_message(result)), self.root.destroy())) def cli() -> int: parser = argparse.ArgumentParser(description="GTI Fiscal Agent Setup (headless)") parser.add_argument("--cli", action="store_true", help="run headless (no GUI)") parser.add_argument("--code", default=None, help="XXXX-XXXX-XXXX-XXXX install code (omit to upgrade an existing install)") parser.add_argument("--hub", default=None, help="Hub base URL (dev override; default prod)") parser.add_argument("--install-dir", default=None, help="install directory (dev override)") parser.add_argument("--no-service", action="store_true", help="skip service registration + ARP (E2E scratch runs)") parser.add_argument("--fdb", default=None, help="Firebird database path (skips discovery)") parser.add_argument("--fb-user", default="SYSDBA") parser.add_argument("--fb-password", default=None) parser.add_argument("--no-firebird", action="store_true", help="scratch E2E without any Firebird database") args = parser.parse_args() if args.hub: os.environ["GTI_HUB_URL"] = args.hub if args.install_dir: os.environ["GTI_INSTALL_DIR"] = args.install_dir if not args.code: result = upgrade_flow(install_dir_path=args.install_dir, progress=print) print(f"UPGRADE_OK agent_id={result['agent_id']} service_running={result['service_running']} heartbeat={result['heartbeat']}") if not result["service_running"]: print(f"SERVICE_START_FAILED {result.get('service_detail')} (see {common.log_path()})", file=sys.stderr) return 3 return 0 explicit = {"path": args.fdb, "user": args.fb_user, "password": args.fb_password or ""} if args.fdb else None result = install_flow(args.code, hub=args.hub, install_dir_path=args.install_dir, register_service=not args.no_service, firebird=explicit, require_firebird=not args.no_firebird, progress=print) print(f"INSTALL_OK agent_id={result.get('agent_id')} install_dir={result.get('install_dir')} hub={result.get('hub_url')} " f"service_running={result.get('service_running')} heartbeat={result.get('heartbeat')}") if result.get("service_detail") and not result.get("service_running"): # a start was attempted and failed if True: print(f"SERVICE_START_FAILED {result.get('service_detail')} (see {common.log_path()})", file=sys.stderr) return 3 return 0 def main(): if "--cli" in sys.argv: try: return cli() except RuntimeError as error: print(f"INSTALL_FAIL {error}", file=sys.stderr) return 1 root = tk.Tk() SetupWizard(root) root.mainloop() return 0 if __name__ == "__main__": sys.exit(main())