Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'

git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
This commit is contained in:
gti
2026-09-29 14:43:23 -03:00
868 changed files with 566394 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_PAYLOAD = ROOT / "dist" / "installer"
PAYLOAD = Path(os.environ.get("GTI_E2E_PAYLOAD", str(DEFAULT_PAYLOAD)))
sys._MEIPASS = str(PAYLOAD) # frozen-equivalent: extract_payload() reads this
sys.path.insert(0, str(ROOT / "agent" / "src"))
sys.path.insert(0, str(ROOT / "installer"))
import setup_main # noqa: E402
sys.exit(setup_main.cli())
+166
View File
@@ -0,0 +1,166 @@
"""Safe, read-only Firebird/SGV discovery for Setup.exe.
Finds candidate .FDB files, and for each one checks (read-only connection, no
writes, ever) that it is an SGV database: it must contain both C000025
(products) and C000004 (company). Exactly one compatible database is selected
automatically; several are NEVER chosen silently — the caller (wizard) asks the
operator during installation. Credentials are only ever used locally.
Pure/injectable on purpose (`connect`, `finder`) so it is unit-testable without
Firebird or a Windows host.
"""
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
# The SGV/Firebird default. Tried silently; anything else is asked for inside the wizard.
DEFAULT_CREDENTIALS: tuple[tuple[str, str], ...] = (("SYSDBA", "masterkey"),)
REQUIRED_TABLES = ("C000025", "C000004")
MAX_DEPTH = 4
MIN_BYTES = 200 * 1024
MAX_FILES = 300
SKIP_DIRS = {"windows", "program files", "program files (x86)", "programdata", "$recycle.bin", "system volume information",
"appdata", "node_modules", ".git", "winsxs", "recovery", "perflogs", "users"}
SKIP_FILES = {"security2.fdb", "security3.fdb", "security4.fdb", "security.fdb"}
@dataclass
class Candidate:
path: str
status: str # OK | NOT_SGV | AUTH_FAILED | ERROR
user: str | None = None
password: str | None = None
detail: str = ""
@dataclass
class Discovery:
state: str # SELECTED | AMBIGUOUS | AUTH_REQUIRED | NONE
compatible: list[Candidate] = field(default_factory=list)
auth_failed: list[Candidate] = field(default_factory=list)
scanned: int = 0
firebird_service: str | None = None
@property
def selected(self) -> Candidate | None:
return self.compatible[0] if self.state == "SELECTED" else None
def fixed_drives() -> list[str]:
try:
import ctypes
mask = ctypes.windll.kernel32.GetLogicalDrives()
drives = []
for i in range(26):
root = f"{chr(65 + i)}:\\"
if mask & (1 << i) and ctypes.windll.kernel32.GetDriveTypeW(root) == 3: # DRIVE_FIXED
drives.append(root)
return drives
except Exception: # noqa: BLE001
return ["C:\\"]
def find_fdb_files(roots: list[str], max_depth: int = MAX_DEPTH, limit: int = MAX_FILES) -> list[str]:
found: list[str] = []
for root in roots:
root = str(root)
base_depth = root.rstrip("\\/").count(os.sep)
for current, dirs, files in os.walk(root, onerror=lambda _e: None):
depth = current.rstrip("\\/").count(os.sep) - base_depth
dirs[:] = [d for d in dirs if d.lower() not in SKIP_DIRS] if depth < max_depth else []
for name in files:
lower = name.lower()
if lower.endswith(".fdb") and lower not in SKIP_FILES:
full = os.path.join(current, name)
try:
if os.path.getsize(full) >= MIN_BYTES:
found.append(full)
except OSError:
continue
if len(found) >= limit:
return found
return found
def default_roots() -> list[str]:
roots: list[str] = []
for var in ("ProgramFiles", "ProgramFiles(x86)"):
base = os.environ.get(var)
if base:
roots.extend(str(p) for p in Path(base).glob("Firebird*"))
roots.extend(fixed_drives())
return roots
def firebird_service() -> str | None:
try:
out = subprocess.run(["sc.exe", "query", "type=", "service", "state=", "all"], capture_output=True, text=True, timeout=20).stdout
except Exception: # noqa: BLE001
return None
for line in out.splitlines():
if line.strip().upper().startswith("SERVICE_NAME") and "FIREBIRD" in line.upper():
return line.split(":", 1)[1].strip()
return None
def _default_connect(path: str, user: str, password: str):
import fdb
return fdb.connect(database=path, user=user, password=password, isolation_level=fdb.ISOLATION_LEVEL_READ_COMMITED_RO)
def probe(path: str, user: str, password: str, *, connect: Callable | None = None) -> Candidate:
connect = connect or _default_connect
try:
conn = connect(path, user, password)
except Exception as error: # noqa: BLE001
text = str(error).lower()
if "user name" in text or "password" in text or "not defined" in text:
return Candidate(path, "AUTH_FAILED", detail="credenciais recusadas")
return Candidate(path, "ERROR", detail=type(error).__name__)
try:
cursor = conn.cursor()
cursor.execute("SELECT TRIM(RDB$RELATION_NAME) FROM RDB$RELATIONS WHERE RDB$RELATION_NAME IN ('C000025','C000004')")
have = {row[0] for row in cursor.fetchall()}
except Exception as error: # noqa: BLE001
return Candidate(path, "ERROR", detail=type(error).__name__)
finally:
try:
conn.close()
except Exception: # noqa: BLE001
pass
if all(table in have for table in REQUIRED_TABLES):
return Candidate(path, "OK", user, password)
return Candidate(path, "NOT_SGV", detail="sem C000025/C000004")
def discover(*, roots: list[str] | None = None, credentials=DEFAULT_CREDENTIALS, connect: Callable | None = None,
finder: Callable | None = None) -> Discovery:
finder = finder or find_fdb_files
files = finder(roots if roots is not None else default_roots())
compatible: list[Candidate] = []
auth_failed: list[Candidate] = []
for path in files:
result = None
for user, password in credentials:
result = probe(path, user, password, connect=connect)
if result.status != "AUTH_FAILED":
break
if result is None:
continue
if result.status == "OK":
compatible.append(result)
elif result.status == "AUTH_FAILED":
auth_failed.append(result)
if len(compatible) == 1:
state = "SELECTED"
elif len(compatible) > 1:
state = "AMBIGUOUS"
elif auth_failed:
state = "AUTH_REQUIRED"
else:
state = "NONE"
return Discovery(state, compatible, auth_failed, len(files), firebird_service())
+351
View File
@@ -0,0 +1,351 @@
"""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 <install>/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
def archive_stale_state(install: Path) -> list[str]:
"""A NEW enrollment (fresh install with a code) means a NEW identity. A state database left behind by a previous installation (uninstall keeps local data) still carries
the old installation_id, which the Agent refuses to mix with the new credential: rename it aside (never delete) so the new installation starts clean."""
moved = []
stamp = time.strftime("%Y%m%d-%H%M%S")
for name in ("agent_state.sqlite", "agent_state.sqlite-wal", "agent_state.sqlite-shm", "agent_state.sqlite-journal"):
path = Path(install) / name
if path.exists():
target = path.with_name(f"{name}.previous-{stamp}")
path.rename(target)
moved.append(target.name)
if moved:
ilog(f"archived state from a previous installation: {', '.join(moved)}")
return moved
+24
View File
@@ -0,0 +1,24 @@
"""GTIFiscalAgentService.exe — frozen Windows Service host.
A thin entry that hands control to the already-bundled
`gti_agent_windows_service` wrapper (which reuses run_agent_service.run()).
It only guarantees the service locates `agent.env` and the DPAPI credential in
the install directory used by Setup.exe, regardless of how SCM launches it.
Built as a PyInstaller onedir; Setup.exe extracts this binary + its
`_internal/` dir into C:\Program Files\GTI\FiscalAgent\.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
INSTALL_DIR = Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "GTI" / "FiscalAgent"
os.environ.setdefault("GTI_AGENT_ENV_FILE", str(INSTALL_DIR / "agent.env"))
from gti_agent_windows_service import main # noqa: E402 (bundled by PyInstaller)
if __name__ == "__main__":
sys.exit(main())
+394
View File
@@ -0,0 +1,394 @@
"""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)
common.archive_stale_state(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()
try:
extract_payload(install)
common.upgrade_agent_env(install, version)
common.install_service(install)
common.write_arp_uninstall(install)
except Exception as error: # noqa: BLE001 — never leave the host without a running Agent because an upgrade step failed
common.ilog(f"upgrade step failed: {error!r}; restarting the existing service")
common.start_and_verify(attempts=2)
raise
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("<Return>", 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 _hide_console():
"""The frozen Setup is a console-subsystem exe (needed for --cli); in GUI mode the black console window behind the wizard is noise, so hide it."""
try:
import ctypes
handle = ctypes.windll.kernel32.GetConsoleWindow()
if handle:
ctypes.windll.user32.ShowWindow(handle, 0)
except Exception: # noqa: BLE001 - cosmetic only
pass
def main():
if "--cli" in sys.argv:
try:
return cli()
except RuntimeError as error:
print(f"INSTALL_FAIL {error}", file=sys.stderr)
return 1
_hide_console()
root = tk.Tk()
SetupWizard(root)
root.mainloop()
return 0
if __name__ == "__main__":
sys.exit(main())
+31
View File
@@ -0,0 +1,31 @@
"""Uninstall.exe — stops and removes the GTI Fiscal Agent service, then removes
the install directory (config, DPAPI credential, service host, state/logs) and
the ARP uninstall key. Invoked from Settings -> Apps -> GTI Fiscal Agent ->
Uninstall. Run elevated (PyInstaller --uac-admin); safe to run twice.
Never connects to the Hub and never reads the credential contents.
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
import install_common as common
def main() -> int:
install = common.install_dir()
common.remove_service()
common.remove_arp_entry()
if install.exists():
try:
shutil.rmtree(install, ignore_errors=True)
except OSError:
pass # leftover locked handle; SCM may still hold the binary briefly
print(f"GTI Fiscal Agent removed from {install}")
return 0
if __name__ == "__main__":
sys.exit(main())