GTI terminates TLS in its Nginx Proxy Manager (10.0.10.100), not on the Hub host, so the Hub must be reachable on the LAN — by the proxy only. - docker-compose.prod.yml passes FORWARDED_ALLOW_IPS (uvicorn then trusts X-Forwarded-For/-Proto from the proxy only: real client IPs in audit and session records, https scheme) and WEB_COOKIE_SECURE. Defaults keep the previous behaviour. - restrict_hub_port.sh + gti-fiscal-hub-port.service: Docker-published ports bypass UFW, so the source restriction lives in DOCKER-USER, matched on the conntrack original destination with --ctdir ORIGINAL, and is re-applied whenever docker.service starts (the chain is not persisted across reboots). - *.service forced to LF like *.sh; tests cover both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
18 KiB
GTI Fiscal — Pilot Deployment Guide
Living document for operating a real pilot: one Hub, one or more client
Agent installations. See docs/OPERATIONS.md for the day-to-day command
reference (creating jobs, checking status); this document is about getting a
new environment stood up and kept alive.
RC1 (1.0.0-rc1) — supported path
Hub central (Docker: Hub + PostgreSQL, deploy/hub)
^ HTTPS (TLS terminated by a reverse proxy)
|
Agent Windows (service "GTIFiscalAgent", deploy/agent-windows)
| local read-only connection
SGV Firebird (the client's C000025 / C000004)
The Hub never accesses Firebird, never stores Firebird credentials and never holds the client's database path. Only the Agent, on the client's Windows host, opens
Firebird. RC1 pilots run FISCAL_MODE=DRY_RUN + FIREBIRD_WRITES=0: the Agent is technically unable to write (no write connection is ever opened, the executor is never called).
Hub (GTI infrastructure)
cd deploy/hub && cp .env.example .env— setPOSTGRES_PASSWORD(openssl rand -hex 24),BASE_URL(public HTTPS URL). LeaveHUB_BIND=127.0.0.1and publish it through the reverse proxy.docker compose -f docker-compose.prod.yml --env-file .env up -d --build— PostgreSQL (internal network only, persistent volume, healthcheck), Hub (migrations at start, healthcheck,restart: unless-stopped, json-file logs 10 MB x 5).- Seed the validated datasets once: on the development machine
sh export_datasets_seed.sh > gti_datasets_seed.dump; on the Hub hostsh seed_datasets.sh gti_datasets_seed.dump(refuses ifdatasetsis not empty; reference tables only, never tenants/agents/jobs). - Register the Agent:
docker compose -f docker-compose.prod.yml exec fiscal-hub python central/scripts/register_agent.py --company "<Client>" --name "<installation>"— the token is printed once; store it only in the Agent's local.env. - Health:
GET /health(Postgres included),GET /version(release/engine/decision versions).APP_ENV=productionrefuses to start whenAGENT_DEV_TOKENis set. - Backup/restore:
sh backup.sh(pg_dump -Fc intoBACKUP_DIR, keeps 14) andsh restore.sh <dump>(asks for the word RESTORE, stops the Hub, restores, restarts; migrations rerun at start).
Agent (client host, PowerShell as Administrator, inside the extracted GTIFiscalAgent-1.0.0-rc1 folder)
Build the package on the release machine: python scripts/build_agent_package.py -> dist/GTIFiscalAgent-1.0.0-rc1.zip (+ .sha256; secret-scanned, no .env).
| step | command |
|---|---|
| install | .\gti-agent.ps1 install (venv + dependencies + service registration; does not start) |
| configure | .\gti-agent.ps1 configure (prompts; secrets never echoed) or configure -FromTemplate .\pilot-construcao.env.example |
| preflight | .\gti-agent.ps1 preflight -> `PREFLIGHT=PASS |
| start / stop / restart | .\gti-agent.ps1 start (refuses when preflight fails) |
| status | .\gti-agent.ps1 status |
| uninstall | .\gti-agent.ps1 uninstall [-Purge] |
Agent configuration (per installation, .env in the install folder)
GTI_HUB_URL, AGENT_ID, AGENT_TOKEN; COMPANY_CODE / COMPANY_CNPJ (company selection), COMPANY_CRT_SOURCE = AUTO | EMISSOR | C000004; COMPANY_UF only as an explicit
override (it never replaces the resolver: it must agree with what the database proves); FIREBIRD_DATABASE_PATH, FB_USER (read-only account), FB_PASSWORD; FISCAL_MODE=DRY_RUN, FIREBIRD_WRITES=0;
POLL_INTERVAL_SECONDS, AGENT_VERSION. AUTO resolves the CRT only when EMISSOR and C000004 agree; a conflict makes the preflight FAIL until EMISSOR or C000004 is chosen explicitly.
The first row of C000004 is never used to pick the company.
Preflight (agent/scripts/preflight.py, also enforced at service start)
HUB_REACHABLE, AUTH, HUB_VERSION_COMPATIBLE (major.minor), FIREBIRD_REACHABLE, FIREBIRD_VERSION_ODS, C000004, C000025, CAPABILITIES (required: NCM, IPI, CEST), COMPANY_CONTEXT (UF, CRT),
SCHEMA_TYPE_GUARDS, SQLITE, LOG_DIRECTORY, FISCAL_MODE, FIREBIRD_WRITES (DRY_RUN + FIREBIRD_WRITES=1 is a misconfiguration -> FAIL).
Pilot profiles
deploy/agent-windows/pilot-construcao.env.example (first pilot: company 000001, UF BA, CRT 1, COMPANY_CRT_SOURCE=AUTO, DRY_RUN). Planned, not installed: profiles/mmmodas|rincao|uniao.env.example
(COMPANY_CRT_SOURCE=EMISSOR required: EMISSOR and C000004 disagree on the CRT). Never reuse the path of a local database copy as the client's real path.
Zero-write proof
python scripts/prove_zero_write.py snapshot <FDB> before.json ... run the pilot ... snapshot <FDB> after.json ... compare before.json after.json (database DML counters, SHA-256 of every row of C000025/C000004, row counts).
The sections below are the previous manual instructions (still accurate for development); the RC1 scripts above are the supported way to deploy.
Architecture
┌────────────────────────────┐
│ Hub server (GTI/Proxmox) │
│ Docker Compose: │
│ - postgres (data volume) │
│ - fiscal-hub (FastAPI) │
└─────────────┬────────────────┘
│ HTTPS (reverse proxy terminates TLS)
┌────────────────────────┼────────────────────────┐
│ │ │
┌─────────▼─────────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐
│ Client A — Agent │ │ Client B — Agent │ │ Client C — Agent │
│ Windows Service │ │ Windows Service │ │ Windows Service │
│ + local Firebird │ │ + local Firebird │ │ + local Firebird │
│ + local SQLite audit│ │ + local SQLite audit│ │ + local SQLite audit│
└────────────────────┘ └────────────────────┘ └────────────────────┘
The Hub never has Firebird credentials and never opens a Firebird connection. Each Agent has its own token and only ever reads/writes the one Firebird database it was configured for.
Hub server requirements
- Docker + Docker Compose v2.
- Enough disk for the Postgres volume (starts small; grows with
classification_cache/jobshistory — no large binary data). - One inbound port for the Hub API (default
8000, or whatever your reverse proxy forwards to it). Postgres itself does not need a host port —docker-compose.ymlintentionally does not publish5432; only containers on the compose network reach it.
Deploying the Hub
git clone <repo> && cd Projeto
cp .env.example .env
# Edit .env: set a real POSTGRES_PASSWORD, leave AGENT_DEV_TOKEN empty,
# set APP_ENV=production, BASE_URL to the public HTTPS URL.
docker compose up -d postgres fiscal-hub
curl http://localhost:8000/health # {"status": "ok"}
fiscal-hub's container command runs alembic upgrade head before starting
uvicorn, so schema migrations apply automatically on every deploy/restart.
Both services have restart: unless-stopped and Docker healthchecks
(pg_isready for Postgres, an HTTP GET to /health for the Hub).
Never put AGENT_DEV_TOKEN in a production .env — it auto-bootstraps
an agent from a fixed shared token on every request, fine for local dev,
wrong for a pilot with real client secrets. Leave it empty; register real
Agents with central/scripts/register_agent.py (see below).
Persistence
- Postgres data lives in the named volume
postgres_data. Back it up withdocker exec <postgres-container> pg_dump -U gti gti_fiscal > backup.sql(orpg_dumpallfor the whole cluster) on whatever schedule your operations policy requires. - Always run
alembic upgrade head(the container does this automatically) rather than hand-editing the schema. An empty database is initialized by that same command — never withalembic stamporcreate_all.0001is an explicit, frozen baseline (it used to callBase.metadata.create_all(), which made0002collide ondatasets.raw_sha256on any fresh database);central/tests/test_migration_baseline.pykeeps that from coming back. 0019repairsagents.created_at/updated_atandjobs.created_at, whose defaults0006had frozen at the instant it ran (rows created before0019keep that frozen value — their real creation time is not recoverable).0020enforces NOT NULL on 20 timestamp columns that always hadDEFAULT now(). It aborts, changing nothing, if any NULL exists — clean those rows explicitly first. Current head:0020_not_null_timestamps(also recorded inrelease/RELEASE_MANIFEST.json).
HTTPS / reverse proxy
The Hub itself speaks plain HTTP. For a pilot reachable outside the Hub's
own host, put it behind a reverse proxy (nginx, Caddy, Traefik — whatever
the GTI/Proxmox environment already standardizes on) that terminates TLS and
forwards to fiscal-hub:8000. Nothing in the app assumes a particular
proxy; BASE_URL in .env is informational only (not currently used to
generate absolute links, but keep it accurate for anyone reading the config).
GTI production (2026-09-23): TLS terminates in the GTI Nginx Proxy Manager
(10.0.10.100), which forwards http://<hub-lan-ip>:8000. On the Hub host:
.env:HUB_BIND=<hub LAN IP>(not0.0.0.0),FORWARDED_ALLOW_IPS=<NPM IP>(real client IP andhttpsscheme fromX-Forwarded-*),BASE_URL=https://…, andWEB_COOKIE_SECURE=1once HTTPS works. Redirects are relative, so the internal address never leaks.- Docker-published ports bypass UFW: install
restrict_hub_port.sh+gti-fiscal-hub-port.service(instructions in the unit file) so only the NPM reaches the port, re-applied on every Docker start.
Registering an Agent (per client installation)
Run once per Agent installation, from anywhere with access to Postgres — easiest is inside the running Hub container:
docker exec <fiscal-hub-container> python central/scripts/register_agent.py \
--company "Client Company Name" --name "loja-01"
This prints AGENT_ID, a freshly generated AGENT_TOKEN (shown once — copy
it immediately), and COMPANY_ID. Only the token's SHA-256 hash is ever
stored in Postgres (same token_hash mechanism /v1/classify already used
— no new auth system). Each Agent gets its own token; one Agent's token
never authenticates another Agent's jobs (agent_id is checked on every
Job operation).
Configuring an Agent installation
On the client machine, copy agent/.env.example to agent/.env and fill in
the values from registration plus that machine's Firebird details:
GTI_HUB_URL=https://hub.example.com
AGENT_ID=<from registration>
AGENT_TOKEN=<from registration>
COMPANY_ID=<from registration>
COMPANY_UF=BA
FIREBIRD_DATABASE_PATH=C:\ERP\dados\PRODUCAO.FDB
FB_USER=...
FB_PASSWORD=...
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=10
AGENT_VERSION=0.1.0
FIREBIRD_WRITES must default to 0. See "Write safety" below before
ever setting it to 1.
Installing the Agent as a Windows Service
Requires pywin32 (pip install pywin32, or pip install -e ".[windows-service]"
from agent/).
# From an elevated (Administrator) terminal, in agent/service/:
python gti_agent_windows_service.py --startup=auto install
python gti_agent_windows_service.py start
# One-time, so Windows restarts it automatically after a crash:
sc.exe failure "GTIFiscalAgent" reset= 86400 actions= restart/60000/restart/60000/restart/60000
gti_agent_windows_service.py is a thin wrapper: it does not reimplement
polling, heartbeat, or the pipeline — it calls run_agent_service.run()
directly, stopping it via the Service Control Manager's own stop signal
instead of SIGINT/SIGTERM (which don't apply to services running with no
console). See the module's docstring for the exact commands.
Install / configure / start / status / stop / restart / uninstall
# Install (registers with SCM, Startup=Automatic)
python gti_agent_windows_service.py --startup=auto install
# Configure: edit agent/.env, no service restart needed until you `restart`
notepad agent\.env
# Start
python gti_agent_windows_service.py start
# Status
sc.exe query GTIFiscalAgent
# Stop
python gti_agent_windows_service.py stop
# Restart (picks up any agent/.env changes)
python gti_agent_windows_service.py restart
# Uninstall
python gti_agent_windows_service.py stop
python gti_agent_windows_service.py remove
Updating the Agent's version
- Stop the service:
python gti_agent_windows_service.py stop - Replace the
agent/code with the new version (git pull, or copy files). - Bump
AGENT_VERSIONinagent/.envif you track it per-release. - Start the service again:
python gti_agent_windows_service.py start. - Confirm the new version shows up via
GET /v1/agents/meon the Hub.
There is no auto-update mechanism yet (deliberately out of scope for this pilot) — updates are a manual stop/replace/start.
Running without installing as a service (dev/testing)
cd agent
python scripts/run_agent_service.py # runs forever, Ctrl+C to stop
python scripts/run_agent_service.py --max-iterations 5 # bounded, for scripting/tests
Creating and checking work
See docs/OPERATIONS.md for the full curl examples
(POST /v1/jobs with kind: REPROCESS_ALL or REPROCESS_PRODUCTS,
GET /v1/jobs/{id} for status). Every Job is scoped to the Agent whose
token created it — no cross-Agent visibility.
Checking Agent status
curl $GTI_HUB_URL/v1/agents/me -H "Authorization: Bearer $AGENT_TOKEN"
Returns agent_id, company_id, enabled, last_seen_at, last_status,
schema_fingerprint, agent_version, and a derived status of ONLINE or
OFFLINE (offline if no heartbeat arrived within
AGENT_ONLINE_THRESHOLD_SECONDS, default 120s — configurable on the Hub).
No dashboard yet; this is the query an operator or a future dashboard would
poll.
Write safety: enabling FIREBIRD_WRITES for a real client
Do not set FIREBIRD_WRITES=1 against a real client database casually.
The authorization model has two independent layers, both required:
- Path:
AUTHORIZED_WRITE_DATABASE_PATHin that installation'sagent/.envmust be set to the exact absolute path ofFIREBIRD_DATABASE_PATHfor that same installation. If it's unset, the guard falls back to the developer-only disposable test database path (TESTESOFICIAIS.FDB), so a real client path is refused automatically — authorization is never inferred from the filename or the.FDBextension. - Schema fingerprint:
EXPECTED_SCHEMA_FINGERPRINTmust equal the live schema fingerprint that installation reports in its heartbeat (schema_fingerprint, visible viaGET /v1/agents/me). If the live schema ever drifts from what was verified, writes are refused until an operator re-verifies and updates this value.
Both checks happen before any write connection is opened
(scripts/firebird_write_executor.py::assert_authorized_write_path /
assert_schema_fingerprint_authorized), and before any product is even
read from Firebird for that job.
Even with both layers satisfied, auto-apply only ever touches
C000025.IPI, and only when the TIPI match is rate_kind=NUMERIC and
unambiguous. No real client database has been written to during this or
any prior mission — all writes so far were against TESTESOFICIAIS.FDB.
Logs and diagnostics
- Agent: rotating local log file (
AGENT_LOG_PATHinagent/.env, defaultagent/logs/agent.log, 5MB × 5 backups). Contains startup, heartbeat outcomes, job received/finished, products processed, write counts, failures, and Hub reconnects — never secrets (FB_PASSWORDandAGENT_TOKENare never logged). Detailed per-product audit is in the Agent's SQLite file (AGENT_STATE_DB, tableipi_write_log). - Hub: structured
loggingoutput (visible viadocker compose logs fiscal-hub) for startup, health-check failures, heartbeats, and the full Job lifecycle (created/claimed/completed/failed, with sanitized errors). Coarse per-Job audit persists in Postgres (jobs.result_summary).
Troubleshooting
| Symptom | Check |
|---|---|
Agent shows OFFLINE |
Is the service running (sc.exe query GTIFiscalAgent)? Check agent/logs/agent.log for heartbeat failures — usually GTI_HUB_URL unreachable or wrong AGENT_TOKEN. |
Job stuck PENDING |
Confirm the Agent that owns it is running and polling (its agent_id must match the Job's agent_id — an Agent never claims another Agent's Job). |
Job FAILED |
GET /v1/jobs/{id} for the sanitized error. Common causes: Firebird unreachable, FIREBIRD_WRITES=1 misconfigured (see "Write safety"), Hub unreachable at report time (the Agent keeps the local ipi_write_log regardless). |
writes_attempted always 0 with FIREBIRD_WRITES=1 |
Check both AUTHORIZED_WRITE_DATABASE_PATH and EXPECTED_SCHEMA_FINGERPRINT are set and correct for that installation. |
Hub /health returns 503 |
Postgres is down or unreachable; check docker compose ps and docker compose logs postgres. |
Rollback / uninstalling an Agent
- Stop and remove the Windows service (see above).
- Delete the
agent/installation directory on that machine (its SQLite audit and logs are local-only; nothing to clean up centrally). - On the Hub, disable the Agent rather than deleting history: set
enabled = falsefor thatagentsrow (no HTTP endpoint for this yet — run it viapsql/a short script againstDATABASE_URL, the same accessregister_agent.pyuses). Its past Jobs andclassification_cacheentries remain for audit.
Rolling back the Hub
docker compose down does not remove the postgres_data volume — data
survives. To roll back to a previous Hub image/version, redeploy that
version's code and run alembic upgrade head again (migrations are
additive, so this is safe going forward; there is no tested downgrade path
for 0006, so don't alembic downgrade in a pilot without restoring from a
backup first).