Files
GTI_GERENCIADOR_FISCAL/docs/superpowers/specs/2026-09-23-windows-installer-exe-v1-design.md
T

14 KiB
Raw Blame History

GTI FISCAL WINDOWS INSTALLER EXE V1 — Design

  • Date: 2026-09-23
  • Status: APPROVED_WITH_ADJUSTMENTS (operator 2026-09-23)
  • Related: previous mission "Distribuição ZIP V1" shipped release 1.1.0.

Objective

Replace the ZIP download UX with a real Windows .EXE installer (GTIFiscalAgent-Setup-<version>.exe) plus an installation/enrollment code per installation. One universal installer per version. Publish release 1.1.1 in the production Hub, run a REAL Windows E2E, and stop before the first client.

Acceptance (operator visible): Fiscal → Agents → Nova instalação → Criar → Baixar Agent → browser downloads GTIFiscalAgent-Setup-1.1.1.exe directly (an executable, not a ZIP). Run the EXE on Windows, type the code, install/enroll. ZIP as the main flow = FAIL.

Approved adjustments (operator)

  1. Setup.exe uses requireAdministrator/--uac-admin manifest. NO hybrid asInvoker + self-elevation flow.
  2. Setup.exe is self-contained: embeds the Service host + runtime + Uninstall.exe. It never downloads a second ZIP during install.
  3. install_code alone resolves the Installation on the Hub. User types only the code. If both install_code and enrollment_token are present, the request is rejected.
  4. Production: the permanent credential NEVER goes into agent.env. It lives only in credentials.bin, protected by DPAPI LocalMachine + ACL SYSTEM/Administrators. agent.env holds only non-secret configuration plus FIREBIRD_WRITES=0.
  5. "Reproducible build" = an automated, repeatable build process and an immutable release — NOT byte-identical hashes across rebuilds. After PUBLISHED, 1.1.1 can never be replaced/overwritten.

Also fixed in this design:

  • 16 Crockford Base32 chars = 80 bits of entropy for the install code.
  • Enrollment rate limit keyed by IP AND by code/installation.
  • Install code is normalized (hyphens/case) before hashing.
  • Release 1.1.0 stays untouched.
  • FIRST_REAL_CLIENT_AGENT_INSTALLED=false this mission.

Installer technology

Chosen: PyInstaller (all-Python). No Inno Setup.

Reasoning (INSTALLER_TECHNOLOGY / INSTALLER_REASON):

  • PyInstaller 6.21 is already installed on the Windows dev machine; Inno Setup is not. A fully automated, fail-fast build is achievable today with pip-managed tools.
  • The enrollment wizard already needs HTTPS (httpx) and DPAPI (pywin32 win32crypt); those are Python layers, so the wizard must be Python regardless. Adding Inno Setup would add a second tool without removing the Python wizard requirement.
  • pywin32 gives the battle-tested Windows Service wrapper, and PyInstaller's own runtime sulution replaces a venv in Program Files, so no Python/pip is needed on clients.
  • The previous mission already validated the service wrapper + sc.exe failure recovery path in the pilot checklist.
  • Reproducible build is achieved with a dedicated PowerShell build script (clean output dir, official VERSION, SHA256, metadata, fail on missing dep); exact byte-identical output is NOT required.

Artifacts (universal; built on the Windows dev machine)

Artifact PyInstaller mode Contents / role
GTIFiscalAgent-Setup-<v>.exe onefile, --uac-admin GUI wizard (tkinter). Embeds Agent runtime sources + service host + Uninstall.exe as bundled data. Enroll → write config → DPAPI credential → extract Service host → register service → preflight. Main download artifact.
GTIFiscalAgentService.exe (+ _internal/) onedir The Windows Service host (bundled inside Setup.exe, extracted to C:\Program Files\GTI\FiscalAgent\). Reuses gti_agent_windows_service.py logic; token comes from credentials.bin.
Uninstall.exe onefile Stops/removes the service, removes Program Files tree + ARP entry. Invoked from Settings → Apps → GT1 Fiscal Agent → Uninstall.

Setup.exe behavior:

  1. Program runs elevated (requireAdministrator manifest). If not elevated, Windows UAC prompts.
  2. tkinter window: title "GTI Fiscal Agent", a single install-code field, Hub URL fixed at https://gtifiscal.gtigrupo.com.br (no prompt; dev override via env / CLI flag only).
  3. "Instalar" → POST /v1/agents/enroll {"install_code": "XXXX-XXXX-XXXX-XXXX"}.
  4. On success (agent_id, agent_token, hub_url, tenant, release info):
    • Write agent.env (non-secret): GTI_HUB_URL, AGENT_ID, COMPANY_ID/UF (optional), FIREBIRD_DATABASE_PATH (blank), FB_USER/FB_PASSWORD blank, FISCAL_MODE=DRY_RUN, FIREBIRD_WRITES=0, AGENT_VERSION, POLL_INTERVAL_SECONDS, AGENT_LOG_PATH, AGENT_STATE_DB, EXPECTED_SCHEMA_FINGERPRINT.
    • Write credentials.bin via DPAPI LocalMachine encryption (win32crypt.CryptProtectData(..., CRYPTPROTECT_LOCAL_MACHINE)); contains AGENT_TOKEN (and optional secrets). Apply ACL: SYSTEM/Administrators only (icacls). agent.env never holds the token.
    • Extract bundled Service host onedir to C:\Program Files\GTI\FiscalAgent\.
    • Install service GTIFiscalAgent (Automatic/Delayed), set recovery sc.exe failure "GTIFiscalAgent" reset= 86400 actions= restart/60000/restart/60000/restart/60000.
  5. Preflight: Firebird path not configured → report "serviço instalado; configure o arquivo de banco para ativar" and do NOT start the service (no error loop). If fully configured → optionally start.
  6. Write ARP uninstall key (DisplayName "GTI Fiscal Agent", UninstallString = Uninstall.exe).
  7. WINDOWS_INSTALL_COMPLETED audit (best effort) once install succeeds.

The installer never stores the enrollment code after success, never embeds any permanent credential, and never enables Firebird/AI writes (no UI to do so).

Installation code (install_code)

  • Format: 16 chars, Crockford Base32 alphabet (0123456789ABCDEFGHJKMNPQRSTVWXYZ — excludes I, L, O, U), grouped XXXX-XXXX-XXXX-XXXX. 16 chars × 5 bits = 80 bits entropy.
  • Generation: secrets.randbelow/random pick of 16 chars; return formatted.
  • Normalization before hashing: uppercase, strip hyphens/spaces.
  • Storage: only SHA-256 of the normalized code. Never the plaintext.
  • Columns on agent_enrollments (migration 0023): install_code_hash (str 64), install_code_expires_at (timestamptz), install_code_attempts (int, default 0), install_code_created_at (timestamptz).
  • TTL: AGENT_ENROLLMENT_TTL_SECONDS (default 86400) reused.
  • One active code per installation at a time; "Gerar novo código" rotates it (old code invalidated, previous hash kept for audit but unusable).
  • Revocation (Revogar) invalidates the code and, when an agent exists, deactivates it.
  • Rate limit: rate_limit.record_attempt(bucket="enroll_code:"+normalized_code_hash or agent_enrollment_id, origin=IP) in addition to per-IP bucket.

Enrollment endpoint (POST /v1/agents/enroll)

  • Body: {"install_code": "..."} OR legacy {"installation_id": ..., "enrollment_token": ...}.
  • If both install_code and enrollment_token are provided → 400, ENROLLMENT_REJECTED (reason INVALID_REQUEST).
  • With install_code (primary):
    • Resolve the Installation by normalized-code hash. No installation_id needed.
    • Validate: exists, status=PENDING (not REVOKED), release PUBLISHED, code not expired, one-time. install_code_attempts guard + rate limit.
    • On success: digest the code (mark used), set installation status ENROLLED, create the Agent with a fresh permanent token, return {agent_id, agent_token, installation_id, hub_url, ...}.
    • ENROLLMENT_STARTED / SUCCEEDED / REJECTED audit; never log the code.
  • Legacy enrollment_token path kept for backward compatibility (published 1.1.0 bundles) but is no longer produced for new installations.

Downloads

  • /agents/releases main button + pending detail + temp link: serve the SAME universal EXE (GTIFiscalAgent-Setup-<v>.exe) stored as the release artifact.
  • Filename/querystring carry no code/token.
  • INSTALLER_DOWNLOADED audit event on download.

Release model

  • New release row: version=1.1.1, platform=windows, architecture=x64, filename=GTIFiscalAgent-Setup-1.1.1.exe, status=PUBLISHED, published_at (per the prior fix), sha256, size_bytes.
  • Storage path generalized (EXE artifact, not hardcoded .zip). The internal ZIP remains a build/debug artifact only (kept under dist/, not the main flow).
  • Release 1.1.0 row and artifact remain untouched.
  • After PUBLISHED, the artifact is immutable: publish script refuses to replace an existing PUBLISHED filename for the same version+platform+arch.

Agent runtime changes (FIREBIRD_WRITES=0, DPAPI credential)

  • New module gti_fiscal_agent/credential_store.py:
    • read_credential(path) — decrypt DPAPI LocalMachine blob (fallback: if the file is a plaintext token, accept in dev only).
    • write_credential(path, payload) (installer side; reused by Setup.exe) — encrypt + set ACL.
    • install_env_from_credentials(path, env) — fill AGENT_TOKEN from credentials.bin; agent.env stays non-secret.
  • Config/main/service wrapper read the token via AGENT_TOKEN if set, else look for GTI_CREDENTIAL_FILE (default C:\Program Files\GTI\FiscalAgent\credentials.bin) and decrypt.
  • FIREBIRD_WRITES=0 enforced (already fail-closed in Config.firebird_writes and open_write_connection); drift test asserts the installer template sets 0 and no UI/flag enables it.

Panel UI changes

  • agent_pending_detail.html + agent_detail.html: show
    • Versão, Status,
    • Código de instalação XXXX-XXXX-XXXX-XXXX + [Copiar]
    • Expira em (formatted TTL deadline)
    • [Baixar instalador (.EXE)] → EXE
    • [Gerar novo código] → rotates code (audit INSTALLATION_CODE_CREATED)
    • [Revogar] → revokes (audit INSTALLATION_CODE_REVOKED / INSTALLATION_REVOKED)
  • /agents/releases: download button → EXE (main flow).
  • Never show raw enrollment tokens; the install code is shown to the operator who created the installation only (SSO-authenticated panel).

Build script

  • scripts/build_windows_installer.ps1
    • Reads official release/VERSION (1.1.1).
    • Cleans dist/installer output.
    • Builds service host (onedir) and Setup.exe (onefile, --uac-admin) and Uninstall.exe with PyInstaller from the bundled sources; Setup embeds the service host dir + Uninstall.exe as data.
    • Computes sha256, writes GTIFiscalAgent-Setup-<v>.exe.sha256.
    • Fails on missing dependency/tool.
    • Deterministic inputs; byte-identical output not required.

Tests (TDD)

Central (central/tests, pytest):

  • install code generation/normalization/hash round trip (format, alphabet, uppercase, hyphen removal, no ambiguous chars).
  • enroll with install_code resolves installation by code alone; success creates enrolled agent + credential issuance; install_code_attempts increments; expiry/revoked/used-one-time → REJECTED; code+token together → 400; rate limit by IP and by code; duplicate/rotation invalidation.
  • migration 0023 up/down.
  • publish script refuses to overwrite a PUBLISHED artifact and stamps published_at.
  • downloads serve EXE with INSTALLER_DOWNLOADED audit.
  • panel pages render code/button actions without leaking tokens.

Agent (agent/tests + root tests): credential_store encrypt/decrypt, DRY_RUN / FIREBIRD_WRITES=0 enforcement, agent.env has no token, service wrapper reads credential.

Build verification: PowerShell script dry-run on dev machine produces EXE + sha256; PyInstaller starts the setup binary (elevation triggered) or we verify artifact layout.

E2E plan

  1. Local: build 1.1.1 EXE; launch against the PRODUCTION Hub once the backend is deployed (or against a local Hub in a scratch container for the UI pass).
  2. Production Hub (real, at https://gtifiscal.gtigrupo.com.br):
    • Create an E2E installation via the panel.
    • Generate an install code.
    • Download the real EXE (assert filename, MIME application/octet-stream / executable, not ZIP).
    • Run on the real Windows dev machine, enter code, complete enrollment (real POST /v1/agents/enroll), verify:
      • Installation → ENROLLED, Agent created.
      • credentials.bin exists (DPAPI-encrypted, not plaintext token).
      • agent.env has no token, FIREBIRD_WRITES=0, blank Firebird path.
      • Service GTIFiscalAgent registered, Automatic/Delayed, recovery set, NOT started (Firebird not configured) — or started if configured.
      • Windows firewall/dir ACL ok (SYSTEM/Administrators).
    • Uninstall via Uninstall.exe → service removed, Program Files cleaned.
    • Revoke/deactivate the E2E agent in the panel; keep audit trail.
    • No real Firebird database is ever connected.
  3. Rejections E2E: invalid code, expired code, replayed/used code (each audited ENROLLMENT_REJECTED), code+token together → 400.

Deploy sequence

  1. Backup production: PostgreSQL logical dump (pg_dump) of gti_fiscal + copy of release storage volume (via container) to the backup location.
  2. Build image with migration 0023; alembic upgrade head (fresh-DB and head checks).
  3. Deploy Hub (overlay, docker compose up -d --build), postgres intact.
  4. Publish release 1.1.1 EXE (immutable).
  5. Production smoke: health endpoint, panel pages, download EXE.
  6. Run production E2E (above).
  7. Validate restore: restore EXE artifact + PG metadata from backup copy.

Constraints (non-negotiables)

  • FIREBIRD_WRITES=0; AI_WRITE_ALLOWED=false; no UI enabling either.
  • No real client install this mission.
  • No SSO/NPM/DNS/hardware/credentials changes.
  • Release 1.1.0 not touched. 1.1.1 immutable once PUBLISHED.
  • CODE_SIGNED=false. SmartScreen warning acceptable in the internal pilot.
  • Credentials never logged/committed; GTI_SSH_PASSWORD env var only.

Report (final)

Populate the mission's exact field list (STATUS, INSTALLER_TECHNOLOGY=PyInstaller, INSTALLER_REASON, ..., PANEL_DOWNLOAD_FILENAME=GTIFiscalAgent-Setup-1.1.1.exe, DPAPI=TRUE, ACL=system+administrators, FIREBIRD_WRITES=0, WINDOWS_E2E=..., etc.).