Files
GTI_GERENCIADOR_FISCAL/fiscal/docs/superpowers/specs/2026-09-23-windows-installer-exe-v1-design.md
T
gti d21a9d1140 Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'
git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
2026-09-29 14:43:23 -03:00

264 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.).