docs: windows installer EXE V1 design (approved with adjustments)

This commit is contained in:
gti
2026-09-23 16:01:44 -03:00
parent 21c926150f
commit 701d7dafdd
@@ -0,0 +1,264 @@
# 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.).