docs: windows installer EXE V1 design (approved with adjustments)
This commit is contained in:
@@ -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.).
|
||||
Reference in New Issue
Block a user