Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'

git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
This commit is contained in:
gti
2026-09-29 14:43:23 -03:00
868 changed files with 566394 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
GTI Fiscal Agent 1.0.0-rc1 - pacote Windows
==========================================
Arquitetura: Hub central --HTTPS--> Agent Windows --local--> Firebird do SGV. O Hub nunca acessa o Firebird.
Este pacote NAO contem segredos (token do Agent e senha do Firebird sao informados localmente).
Requisitos no host do cliente: Python 3.12 (64 bits) no PATH, cliente Firebird (fbclient.dll, o mesmo do SGV),
acesso HTTPS ao Hub, uma conta Firebird somente leitura.
Passo a passo (PowerShell como Administrador, dentro desta pasta):
.\gti-agent.ps1 install cria o venv, instala as dependencias, registra o servico "GTIFiscalAgent" (nao inicia)
.\gti-agent.ps1 configure (ou: configure -FromTemplate .\pilot-construcao.env.example) grava o .env local com ACL restrita
.\gti-agent.ps1 preflight PREFLIGHT=PASS|FAIL e os bloqueios, se houver
.\gti-agent.ps1 start so inicia se o preflight passar
.\gti-agent.ps1 status | stop | restart | uninstall [-Purge]
Modo do piloto: FISCAL_MODE=DRY_RUN e FIREBIRD_WRITES=0. Nesse modo o Agent e tecnicamente incapaz de escrever no Firebird.
Detalhes: docs/PILOT_DEPLOYMENT.md e docs/OPERATIONS.md do repositorio.
@@ -0,0 +1,22 @@
# GTI Fiscal Agent - generic installation template
# NO SECRETS in this file. Copy to <install>\.env (gti-agent configure -FromTemplate) and fill AGENT_TOKEN / FB_PASSWORD locally.
GTI_HUB_URL=https://fiscal.example.gti
AGENT_TOKEN=
AGENT_ID=
# Company context (deterministic resolver; never the first row of C000004)
COMPANY_CODE=
COMPANY_CNPJ=
# AUTO = resolve automatically ONLY when there is no CRT conflict; otherwise preflight FAILS until EMISSOR or C000004 is chosen explicitly
COMPANY_CRT_SOURCE=AUTO
# COMPANY_UF= is an explicit override only when the database proves nothing; it never replaces the resolver silently.
# Real path of the client's SGV database ON THIS HOST (do not reuse the path of any local copy)
FIREBIRD_DATABASE_PATH=
FB_USER=
FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.3.6
@@ -0,0 +1,3 @@
@echo off
rem GTI Fiscal Agent - launcher: gti-agent.cmd install|configure|preflight|start|stop|restart|status|uninstall
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0gti-agent.ps1" %*
+215
View File
@@ -0,0 +1,215 @@
<#
GTI Fiscal Agent - Windows operations script (RC1). No Python editing needed.
Usage (elevated PowerShell for install/install-auto/start/stop/restart/uninstall):
.\gti-agent.ps1 install-auto bundle downloaded from the panel's "Nova instalação": verifies the
package's SHA-256, enrolls with the Hub using enrollment.json (deleted
right after, single use), writes .env, registers the service. Does NOT
start it — Firebird is still unconfigured; fill it in and run start.
.\gti-agent.ps1 install manual path: create venv, install dependencies, register the Windows Service (does NOT start it)
.\gti-agent.ps1 configure [-FromTemplate f] write <install>\.env (secrets are prompted, never echoed, never stored in the package)
.\gti-agent.ps1 preflight PREFLIGHT=PASS|FAIL with blockers (read-only)
.\gti-agent.ps1 start | stop | restart service control (start refuses to run when preflight fails)
.\gti-agent.ps1 status service state + last log lines
.\gti-agent.ps1 uninstall [-Purge] remove the service; -Purge also deletes venv/logs/state (never the .env unless -Purge)
The package folder is the install folder. Requires Python 3.12 (64-bit) on PATH and the Firebird client (fbclient.dll) already used by the SGV.
#>
param(
[Parameter(Position = 0, Mandatory = $true)]
[ValidateSet('install', 'install-auto', 'configure', 'preflight', 'start', 'stop', 'restart', 'status', 'uninstall')]
[string]$Command,
[string]$FromTemplate,
[switch]$Purge
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
$ServiceName = 'GTIFiscalAgent'
$Venv = Join-Path $Root 'venv'
$Py = Join-Path $Venv 'Scripts\python.exe'
$EnvFile = Join-Path $Root '.env'
$LogFile = Join-Path $Root 'agent\logs\agent.log'
function Assert-Admin {
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this command from an elevated (Administrator) PowerShell.' }
}
function Read-Plain([string]$Prompt, [string]$Default = '') {
$suffix = if ($Default) { " [$Default]" } else { '' }
$value = Read-Host "$Prompt$suffix"
if ([string]::IsNullOrWhiteSpace($value)) { return $Default } else { return $value.Trim() }
}
function Read-Secret([string]$Prompt) {
$secure = Read-Host $Prompt -AsSecureString
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
}
switch ($Command) {
'install' {
Assert-Admin
if (-not (Get-Command python -ErrorAction SilentlyContinue)) { throw 'Python 3.12 (64-bit) is required on PATH.' }
if (-not (Test-Path $Py)) { python -m venv $Venv }
& $Py -m pip install --upgrade pip | Out-Null
& $Py -m pip install -r (Join-Path $Root 'requirements-agent.txt')
$post = Join-Path $Venv 'Scripts\pywin32_postinstall.py'
if (Test-Path $post) { & $Py $post -install | Out-Null }
$svc = Join-Path $Root 'agent\service\gti_agent_windows_service.py'
& $Py $svc --startup=auto install
sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/restart/60000 | Out-Null
Write-Host "Service $ServiceName registered (Automatic). Next: .\gti-agent.ps1 configure ; .\gti-agent.ps1 preflight ; .\gti-agent.ps1 start"
}
'install-auto' {
Assert-Admin
$EnrollmentFile = Join-Path $Root 'enrollment.json'
$ManifestFile = Join-Path $Root 'MANIFEST.json'
# Idempotent: a second run after a successful enroll must not re-enroll,
# re-register the service, or touch the existing identity.
if ((Test-Path $EnvFile) -and (Select-String -Path $EnvFile -Pattern '^AGENT_TOKEN=\S' -Quiet) -and (Get-Service $ServiceName -ErrorAction SilentlyContinue)) {
Write-Host "Already enrolled and installed ($EnvFile exists, service $ServiceName registered). Nothing to do."
exit 0
}
if (-not (Test-Path $EnrollmentFile)) { throw "enrollment.json not found in $Root — this bundle was already installed, or was not downloaded from a pending installation link." }
if (-not (Get-Command python -ErrorAction SilentlyContinue)) { throw 'Python 3.12 (64-bit) is required on PATH.' }
$enrollment = Get-Content $EnrollmentFile -Raw | ConvertFrom-Json
if ($enrollment.hub_url -notmatch '^https://') { throw "Refusing: hub_url must be https:// (got: $($enrollment.hub_url))" }
# Package integrity: every file this manifest lists must still match its
# recorded SHA-256 — catches a corrupted or tampered extraction before
# anything in it runs or is installed as a service.
if (Test-Path $ManifestFile) {
$manifest = Get-Content $ManifestFile -Raw | ConvertFrom-Json
$checked = 0
foreach ($entry in $manifest.files.PSObject.Properties) {
$target = Join-Path $Root $entry.Name
if (-not (Test-Path $target)) { throw "SHA256_VALIDATION=FAIL: missing file $($entry.Name)" }
$actual = (Get-FileHash -Path $target -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $entry.Value) { throw "SHA256_VALIDATION=FAIL: $($entry.Name) does not match the published manifest" }
$checked++
}
Write-Host "SHA256_VALIDATION=PASS ($checked files)"
} else {
Write-Host 'SHA256_VALIDATION=SKIPPED (no MANIFEST.json in this bundle)'
}
$agentVersion = ((Get-Content (Join-Path $Root 'VERSION') -Raw) -replace '\s', '')
$machineId = try { (Get-CimInstance Win32_ComputerSystemProduct -ErrorAction Stop).UUID } catch { '' }
$body = @{
installation_id = $enrollment.installation_id
enrollment_token = $enrollment.enrollment_token
agent_version = $agentVersion
hostname = $env:COMPUTERNAME
platform = 'windows'
machine_fingerprint = $machineId
} | ConvertTo-Json
Write-Host "Enrolling installation $($enrollment.installation_id) at $($enrollment.hub_url)..."
try {
$response = Invoke-RestMethod -Method Post -Uri "$($enrollment.hub_url)$($enrollment.enroll_path)" -Body $body -ContentType 'application/json'
} catch {
throw "ENROLLMENT_REJECTED: $($_.Exception.Message)"
}
if ($response.hostname_mismatch) {
Write-Warning 'hostname_mismatch=true: the operator expected a different hostname for this installation. Enrollment still succeeded — confirm this is the intended machine.'
}
# The Hub already rejects any reuse of this token, but the bundle's own
# copy is deleted immediately too, so it never lingers on disk.
Remove-Item $EnrollmentFile -Force
$lines = @(
"GTI_HUB_URL=$($response.hub_url)", "AGENT_TOKEN=$($response.agent_token)", "AGENT_ID=$($response.agent_id)",
'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', "AGENT_VERSION=$agentVersion",
'FIREBIRD_DATABASE_PATH=', 'FB_USER=SYSDBA', 'FB_PASSWORD=', 'COMPANY_CODE=', 'COMPANY_CNPJ=', 'COMPANY_CRT_SOURCE=AUTO'
)
Set-Content -Path $EnvFile -Value $lines -Encoding UTF8
# Only Administrators and SYSTEM may read the file that now holds AGENT_TOKEN.
icacls $EnvFile /inheritance:r /grant:r 'Administrators:(F)' 'SYSTEM:(F)' | Out-Null
if (-not (Test-Path $Py)) { python -m venv $Venv }
& $Py -m pip install --upgrade pip | Out-Null
& $Py -m pip install -r (Join-Path $Root 'requirements-agent.txt')
$post = Join-Path $Venv 'Scripts\pywin32_postinstall.py'
if (Test-Path $post) { & $Py $post -install | Out-Null }
$svc = Join-Path $Root 'agent\service\gti_agent_windows_service.py'
& $Py $svc --startup=auto install
sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/restart/60000 | Out-Null
Write-Host ''
Write-Host "ENROLLMENT_SUCCEEDED: agent_id=$($response.agent_id) hub=$($response.hub_url)"
Write-Host "Service $ServiceName registered (Automatic) but NOT started — Firebird is still unconfigured (by design: this script never guesses a client's database path or credentials)."
Write-Host "Next: edit $EnvFile (FIREBIRD_DATABASE_PATH, FB_USER, FB_PASSWORD, and COMPANY_CODE/COMPANY_CNPJ if needed),"
Write-Host ' then: .\gti-agent.ps1 preflight ; .\gti-agent.ps1 start'
}
'configure' {
if ($FromTemplate) {
Copy-Item -Path $FromTemplate -Destination $EnvFile -Force
Write-Host "Template copied to $EnvFile. Fill the empty secret values (AGENT_TOKEN, FB_PASSWORD) with a text editor, then run preflight."
} else {
$hub = Read-Plain 'Hub URL (https://...)'
$token = Read-Secret 'AGENT_TOKEN (from register_agent.py)'
$agentId = Read-Plain 'AGENT_ID (label for this installation)'
$db = Read-Plain 'FIREBIRD_DATABASE_PATH (real path of the SGV .FDB on THIS host)'
$fbUser = Read-Plain 'FB_USER (read-only account)' 'SYSDBA'
$fbPass = Read-Secret 'FB_PASSWORD'
$code = Read-Plain 'COMPANY_CODE (C000004.CODIGO; empty = auto when unambiguous)'
$cnpj = Read-Plain 'COMPANY_CNPJ (digits; optional alternative to COMPANY_CODE)'
$crtSource = Read-Plain 'COMPANY_CRT_SOURCE (AUTO | EMISSOR | C000004)' 'AUTO'
$lines = @(
"GTI_HUB_URL=$hub", "AGENT_TOKEN=$token", "AGENT_ID=$agentId",
"COMPANY_CODE=$code", "COMPANY_CNPJ=$cnpj", "COMPANY_CRT_SOURCE=$crtSource",
"FIREBIRD_DATABASE_PATH=$db", "FB_USER=$fbUser", "FB_PASSWORD=$fbPass",
'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.3.6'
)
Set-Content -Path $EnvFile -Value $lines -Encoding UTF8
}
# Only Administrators and SYSTEM may read the file that holds the secrets.
icacls $EnvFile /inheritance:r /grant:r 'Administrators:(F)' 'SYSTEM:(F)' | Out-Null
Write-Host "Configuration saved to $EnvFile (restricted ACL). Secrets are not part of the package."
}
'preflight' {
if (-not (Test-Path $Py)) { throw 'Run install first.' }
& $Py (Join-Path $Root 'agent\scripts\preflight.py') --env-file $EnvFile
exit $LASTEXITCODE
}
'start' {
Assert-Admin
& $Py (Join-Path $Root 'agent\scripts\preflight.py') --env-file $EnvFile
if ($LASTEXITCODE -ne 0) { throw 'PREFLIGHT=FAIL: fix the blockers above before starting the service.' }
Start-Service $ServiceName
Write-Host "$ServiceName started."
}
'stop' { Assert-Admin; Stop-Service $ServiceName -Force; Write-Host "$ServiceName stopped." }
'restart' {
Assert-Admin
& $Py (Join-Path $Root 'agent\scripts\preflight.py') --env-file $EnvFile
if ($LASTEXITCODE -ne 0) { throw 'PREFLIGHT=FAIL: not restarting.' }
Restart-Service $ServiceName -Force
Write-Host "$ServiceName restarted."
}
'status' {
$svc = Get-Service $ServiceName -ErrorAction SilentlyContinue
if ($null -eq $svc) { Write-Host 'Service not installed.'; exit 1 }
Write-Host ("Service {0}: {1} (StartType {2})" -f $svc.Name, $svc.Status, $svc.StartType)
if (Test-Path $LogFile) { Write-Host '--- last log lines ---'; Get-Content $LogFile -Tail 15 }
}
'uninstall' {
Assert-Admin
$svcScript = Join-Path $Root 'agent\service\gti_agent_windows_service.py'
if (Get-Service $ServiceName -ErrorAction SilentlyContinue) {
Stop-Service $ServiceName -Force -ErrorAction SilentlyContinue
if (Test-Path $Py) { & $Py $svcScript remove }
}
if ($Purge) {
foreach ($p in @($Venv, (Join-Path $Root 'agent\logs'), (Join-Path $Root 'agent\agent_state.sqlite'), $EnvFile)) { if (Test-Path $p) { Remove-Item $p -Recurse -Force } }
Write-Host 'Service removed; venv, logs, state and .env deleted.'
} else {
Write-Host 'Service removed. Configuration (.env), logs and state were kept (use -Purge to delete them).'
}
}
}
@@ -0,0 +1,22 @@
# GTI Fiscal Agent - PILOT PROFILE: CONSTRUCAO (materiais de construcao). Audited: company 000001, UF BA, CRT 1, AUTO_RESOLVED, confidence HIGH
# NO SECRETS in this file. Copy to <install>\.env (gti-agent configure -FromTemplate) and fill AGENT_TOKEN / FB_PASSWORD locally.
GTI_HUB_URL=https://fiscal.example.gti
AGENT_TOKEN=
AGENT_ID=
# Company context (deterministic resolver; never the first row of C000004)
COMPANY_CODE=000001
COMPANY_CNPJ=
# AUTO is enough here: EMISSOR.EMITENTE_CRT and C000004.CRT agree (1). The host preflight must confirm UF=BA / CRT=1.
COMPANY_CRT_SOURCE=AUTO
# COMPANY_UF= is an explicit override only when the database proves nothing; it never replaces the resolver silently.
# Real path of the client's SGV database ON THIS HOST (do not reuse the path of any local copy)
FIREBIRD_DATABASE_PATH=
FB_USER=
FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.3.6
@@ -0,0 +1,22 @@
# GTI Fiscal Agent - PLANNED PROFILE (not installed): MMMODAS (moda). C000004.CRT=2 vs EMISSOR.EMITENTE_CRT=1
# NO SECRETS in this file. Copy to <install>\.env (gti-agent configure -FromTemplate) and fill AGENT_TOKEN / FB_PASSWORD locally.
GTI_HUB_URL=https://fiscal.example.gti
AGENT_TOKEN=
AGENT_ID=
# Company context (deterministic resolver; never the first row of C000004)
COMPANY_CODE=000001
COMPANY_CNPJ=
# REQUIRED: CRT sources conflict (EMISSOR=1, C000004=2); the emission programs read EMISSOR.
COMPANY_CRT_SOURCE=EMISSOR
# COMPANY_UF= is an explicit override only when the database proves nothing; it never replaces the resolver silently.
# Real path of the client's SGV database ON THIS HOST (do not reuse the path of any local copy)
FIREBIRD_DATABASE_PATH=
FB_USER=
FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.3.6
@@ -0,0 +1,22 @@
# GTI Fiscal Agent - PLANNED PROFILE (not installed): RINCAO (restaurante). C000004 has 3 company rows; EMISSOR.EMITENTE_CRT=3 vs C000004(row 000001).CRT=1
# NO SECRETS in this file. Copy to <install>\.env (gti-agent configure -FromTemplate) and fill AGENT_TOKEN / FB_PASSWORD locally.
GTI_HUB_URL=https://fiscal.example.gti
AGENT_TOKEN=
AGENT_ID=
# Company context (deterministic resolver; never the first row of C000004)
COMPANY_CODE=000001
COMPANY_CNPJ=
# REQUIRED: CRT sources conflict (EMISSOR=3, C000004=1); the emission programs read EMISSOR. COMPANY_CODE=000001 is the row whose CNPJ equals the EMISSOR CNPJ.
COMPANY_CRT_SOURCE=EMISSOR
# COMPANY_UF= is an explicit override only when the database proves nothing; it never replaces the resolver silently.
# Real path of the client's SGV database ON THIS HOST (do not reuse the path of any local copy)
FIREBIRD_DATABASE_PATH=
FB_USER=
FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.3.6
@@ -0,0 +1,22 @@
# GTI Fiscal Agent - PLANNED PROFILE (not installed): UNIAO (mercado). C000004.CRT=3 vs EMISSOR.EMITENTE_CRT=1
# NO SECRETS in this file. Copy to <install>\.env (gti-agent configure -FromTemplate) and fill AGENT_TOKEN / FB_PASSWORD locally.
GTI_HUB_URL=https://fiscal.example.gti
AGENT_TOKEN=
AGENT_ID=
# Company context (deterministic resolver; never the first row of C000004)
COMPANY_CODE=000001
COMPANY_CNPJ=
# REQUIRED: CRT sources conflict (EMISSOR=1, C000004=3; OPTANTE_SIMPLES=S); the emission programs read EMISSOR.
COMPANY_CRT_SOURCE=EMISSOR
# COMPANY_UF= is an explicit override only when the database proves nothing; it never replaces the resolver silently.
# Real path of the client's SGV database ON THIS HOST (do not reuse the path of any local copy)
FIREBIRD_DATABASE_PATH=
FB_USER=
FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.3.6
@@ -0,0 +1,3 @@
httpx>=0.27,<1
fdb>=2.0.2,<3
pywin32>=306; sys_platform == "win32"
+56
View File
@@ -0,0 +1,56 @@
# GTI Fiscal Hub — production environment (copy to .env, never commit .env)
GTI_FISCAL_VERSION=1.3.6
# REQUIRED. URL-safe secret (used inside a connection URL): openssl rand -hex 24
POSTGRES_PASSWORD=
# REQUIRED. Public HTTPS URL that Agents use (TLS terminates in the reverse proxy in front of the Hub).
BASE_URL=https://fiscal.example.gti
# Interface the Hub listens on. Keep 127.0.0.1 when a reverse proxy runs on the same host; use the LAN IP only behind a firewall.
# Docker-published ports bypass UFW: restrict the source in the DOCKER-USER chain (see docs/PILOT_DEPLOYMENT.md).
HUB_BIND=127.0.0.1
HUB_PORT=8000
# IP(s) of the reverse proxy allowed to set X-Forwarded-For/-Proto (comma-separated). 127.0.0.1 = same host.
FORWARDED_ALLOW_IPS=127.0.0.1
# 1 once HTTPS terminates in front of the Hub (session/CSRF cookies become Secure).
WEB_COOKIE_SECURE=0
# SSO from GTI Gerenciador (docs/PILOT_DEPLOYMENT.md, "Human access"). Needs HTTPS (WEB_COOKIE_SECURE=1).
# PUBLIC key only (raw Ed25519, base64url) — the private key lives only in the panel.
SSO_GERENCIADOR_CHAVE_PUBLICA=
SSO_START_URL=https://gtigerenciador.example.gti/sso/fiscal
# Collaborator session lifetime; afterwards they are signed in again transparently.
FEDERATED_SESSION_SECONDS=3600
LOG_LEVEL=INFO
AGENT_ONLINE_THRESHOLD_SECONDS=120
# Agent distribution (docs/PILOT_DEPLOYMENT.md, "Agent distribution"). Defaults
# are safe; override only if the pilot needs a longer/shorter window.
# AGENT_DOWNLOAD_LINK_TTL_SECONDS=1800
# AGENT_DOWNLOAD_MAX_USES_DEFAULT=1
# AGENT_ENROLLMENT_TTL_SECONDS=86400
# Client mirror from GTI Gerenciador (docs/PILOT_DEPLOYMENT.md, "Client integration").
# GTI Gerenciador is the single source of truth for clients — the Fiscal Hub never
# gets MySQL credentials. Both empty = sync disabled (no client selector data).
GERENCIADOR_INTERNAL_URL=https://gtigerenciador.example.gti/internal/fiscal/clients
# Dedicated bearer token, shared only with the panel's FISCAL_INTERNAL_API_TOKEN —
# never SESSION_SECRET, the SSO key, an admin password or an Agent token.
FISCAL_INTERNAL_API_TOKEN=
# Directory (host) where pg_dump backups are written by backup.sh
BACKUP_DIR=./backups
# Max whole-database jobs (INITIAL_SYNC / REPROCESS_ALL) running at once across all Agents; others queue as PENDING. <=0 disables.
HEAVY_JOB_CONCURRENCY_LIMIT=3
# Product-enrichment providers (free plans) - fill on the SERVER only, never commit values
OSCBR_TOKEN=
OSCBR_USER=
OSCBR_PASSWORD=
KODEBAR_API_KEY=
COSMOS_ENABLED=0
OSCBR_BACKFILL_ENABLED=0
PROVIDER_OPERATIONAL_RESERVE_PERCENT=20
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env sh
# PostgreSQL + release-storage backup of the GTI Fiscal Hub (restorable with
# restore.sh + restore_releases.sh). Run from deploy/hub.
# ./backup.sh -> ./backups/gti_fiscal_YYYYmmdd_HHMMSS.dump (Postgres, last 14 kept)
# -> ./backups/gti_fiscal_releases_YYYYmmdd_HHMMSS.tgz (release binaries + AgentRelease metadata is IN the dump above)
set -eu
COMPOSE="docker compose -f docker-compose.prod.yml --env-file .env"
# BACKUP_DIR (like every other setting) lives in .env, but --env-file only
# feeds the container — a plain shell script never gets it unless it loads
# the file itself. Without this, BACKUP_DIR silently falls back to ./backups
# here while the Hub's containers still read the real value from .env.
if [ -f .env ]; then set -a; . ./.env; set +a; fi
DIR="${BACKUP_DIR:-./backups}"
mkdir -p "$DIR"
STAMP="$(date +%Y%m%d_%H%M%S)"
FILE="gti_fiscal_${STAMP}.dump"
$COMPOSE exec -T postgres pg_dump -U gti -d gti_fiscal -Fc -f "/backups/${FILE}"
[ -s "$DIR/$FILE" ] || { echo "backup empty or missing: $DIR/$FILE" >&2; exit 1; }
# Retention runs inside the container: Docker creates the bind-mounted
# backup directory as root, so a host-side rm would fail from the 15th dump.
$COMPOSE exec -T postgres sh -c 'ls -1t /backups/gti_fiscal_*.dump | tail -n +15 | xargs -r rm -f'
echo "backup ok: $DIR/$FILE"
# Release storage (central/app/release_storage.py: every published Agent
# package zip/exe, never in Postgres — only its metadata is, in the dump
# above). Streamed out of the fiscal-hub container's stdout so this works
# regardless of the compose project/container name.
RELEASES_FILE="gti_fiscal_releases_${STAMP}.tgz"
$COMPOSE exec -T fiscal-hub tar -C /app/data -cz releases > "$DIR/$RELEASES_FILE"
[ -s "$DIR/$RELEASES_FILE" ] || { echo "release backup empty or missing: $DIR/$RELEASES_FILE" >&2; exit 1; }
find "$DIR" -maxdepth 1 -name 'gti_fiscal_releases_*.tgz' -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | tail -n +15 | cut -d' ' -f2- | xargs -r rm -f
echo "release storage backup ok: $DIR/$RELEASES_FILE"
+100
View File
@@ -0,0 +1,100 @@
# GTI Fiscal Hub — production compose (RC1). Only two containers: PostgreSQL + Hub. No Kubernetes/Redis/RabbitMQ.
#
# cd deploy/hub && cp .env.example .env && (edit .env) && docker compose -f docker-compose.prod.yml --env-file .env up -d --build
#
# PostgreSQL is on an internal network only: it publishes NO port and is not reachable from the LAN/internet.
# The Hub binds to HUB_BIND (default 127.0.0.1): put a TLS reverse proxy (HTTPS) in front of it for the Agents.
# Migrations run automatically at Hub start (Dockerfile CMD: alembic upgrade head).
name: gti-fiscal-hub
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: gti_fiscal
POSTGRES_USER: gti
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required (URL-safe, e.g. openssl rand -hex 24)}
volumes:
- postgres_data:/var/lib/postgresql/data
- ${BACKUP_DIR:-./backups}:/backups
networks: [internal]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gti -d gti_fiscal"]
interval: 10s
timeout: 5s
retries: 12
logging: *logging
fiscal-hub:
build:
context: ../..
dockerfile: central/Dockerfile
image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.3.6}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
APP_ENV: production
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
# Product-enrichment providers (free plans). Secrets live ONLY in deploy/hub/.env on the server (never committed, never in the Agent/installer/panel/logs).
OSCBR_TOKEN: ${OSCBR_TOKEN:-}
OSCBR_USER: ${OSCBR_USER:-}
OSCBR_PASSWORD: ${OSCBR_PASSWORD:-}
KODEBAR_API_KEY: ${KODEBAR_API_KEY:-}
COSMOS_ENABLED: ${COSMOS_ENABLED:-0}
OSCBR_BACKFILL_ENABLED: ${OSCBR_BACKFILL_ENABLED:-0}
PROVIDER_OPERATIONAL_RESERVE_PERCENT: ${PROVIDER_OPERATIONAL_RESERVE_PERCENT:-20}
BASE_URL: ${BASE_URL:?BASE_URL is required (public HTTPS URL of the Hub)}
AGENT_ONLINE_THRESHOLD_SECONDS: ${AGENT_ONLINE_THRESHOLD_SECONDS:-120}
# Reverse proxy on another host: uvicorn only honours X-Forwarded-For/-Proto
# from these addresses (real client IP in audit/session records, scheme=https).
FORWARDED_ALLOW_IPS: ${FORWARDED_ALLOW_IPS:-127.0.0.1}
# 1 = session/CSRF cookies are sent only over HTTPS. Set once TLS is in front.
WEB_COOKIE_SECURE: ${WEB_COOKIE_SECURE:-0}
# SSO from GTI Gerenciador (central/app/sso.py): the panel's Ed25519 PUBLIC key
# and its /sso/fiscal URL. Both empty = SSO off (local login only).
SSO_GERENCIADOR_CHAVE_PUBLICA: ${SSO_GERENCIADOR_CHAVE_PUBLICA:-}
SSO_START_URL: ${SSO_START_URL:-}
FEDERATED_SESSION_SECONDS: ${FEDERATED_SESSION_SECONDS:-3600}
# AGENT_DEV_TOKEN is intentionally NOT set: real Agents are registered with central/scripts/register_agent.py
# (manually) or auto-provisioned via a successful POST /v1/agents/enroll (secure enrollment).
# Agent distribution (central/app/agent_distribution.py): the universal package zip lives on the
# agent_releases volume below, never in PostgreSQL. TTLs have safe defaults; override only if needed.
AGENT_RELEASE_STORAGE_PATH: /app/data/releases
AGENT_DOWNLOAD_LINK_TTL_SECONDS: ${AGENT_DOWNLOAD_LINK_TTL_SECONDS:-1800}
AGENT_DOWNLOAD_MAX_USES_DEFAULT: ${AGENT_DOWNLOAD_MAX_USES_DEFAULT:-1}
AGENT_ENROLLMENT_TTL_SECONDS: ${AGENT_ENROLLMENT_TTL_SECONDS:-86400}
# Client mirror from GTI Gerenciador (central/app/client_sync.py): the panel is
# the single source of truth for clients. Both empty = sync disabled.
GERENCIADOR_INTERNAL_URL: ${GERENCIADOR_INTERNAL_URL:-}
FISCAL_INTERNAL_API_TOKEN: ${FISCAL_INTERNAL_API_TOKEN:-}
volumes:
- agent_releases:/app/data/releases
ports:
- "${HUB_BIND:-127.0.0.1}:${HUB_PORT:-8000}:8000"
networks: [internal, edge]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
logging: *logging
networks:
internal:
internal: true # no route to the outside: PostgreSQL is never exposed
edge: {}
volumes:
postgres_data:
agent_releases:
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env sh
# Run on the machine that holds the validated fiscal datasets (development Hub). Exports ONLY the reference tables
# (datasets + fiscal entries), never tenants/agents/jobs/classification data, so a fresh production Hub can be seeded:
#
# ./export_datasets_seed.sh projeto-postgres-1 > gti_datasets_seed.dump
# (copy to the production host, then) ./seed_datasets.sh gti_datasets_seed.dump
set -eu
CONTAINER="${1:-projeto-postgres-1}"
TABLES="datasets ncm_entries legacy_ncm_entries cest_entries cest_ncm_entries tipi_entries ba_st_mva_entries ba_st_mva_ncm_sh fiscal_code_entries cfop_entries fcp_rates anp_product_codes ncm_utrib_entries rtc_biofuel_index_entries rtc_cred_pres_entries rtc_cclasstrib_entries rtc_cst_ibscbs_entries rtc_cbs_rate_entries"
ARGS=""
for t in $TABLES; do ARGS="$ARGS -t $t"; done
docker exec "$CONTAINER" pg_dump -U gti -d gti_fiscal -Fc --data-only $ARGS
@@ -0,0 +1,20 @@
# Re-applies the Hub port restriction (restrict_hub_port.sh) whenever Docker starts.
# Install:
# sudo install -m 0755 restrict_hub_port.sh /usr/local/sbin/gti-fiscal-restrict-hub-port
# printf 'HUB_BIND=10.0.10.117\nHUB_PORT=8000\nPROXY_IP=10.0.10.100\nIFACE=ens18\n' | sudo tee /etc/default/gti-fiscal-hub-port
# sudo install -m 0644 gti-fiscal-hub-port.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now gti-fiscal-hub-port.service
[Unit]
Description=GTI Fiscal: allow the Hub port only from the reverse proxy
After=docker.service
Requires=docker.service
PartOf=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
EnvironmentFile=/etc/default/gti-fiscal-hub-port
ExecStart=/usr/local/sbin/gti-fiscal-restrict-hub-port
[Install]
WantedBy=docker.service
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env sh
# Restore a Hub backup into PostgreSQL. DESTRUCTIVE for the current data: asks for confirmation.
# ./restore.sh ./backups/gti_fiscal_20260919_120000.dump
set -eu
[ $# -eq 1 ] || { echo "usage: $0 <backup.dump>" >&2; exit 2; }
FILE="$(basename "$1")"
COMPOSE="docker compose -f docker-compose.prod.yml --env-file .env"
# See backup.sh: BACKUP_DIR lives in .env, which --env-file only feeds to
# the container, not to this shell script.
if [ -f .env ]; then set -a; . ./.env; set +a; fi
[ -s "${BACKUP_DIR:-./backups}/$FILE" ] || { echo "backup not found in ${BACKUP_DIR:-./backups}: $FILE" >&2; exit 1; }
printf "This REPLACES the current Hub database with %s. Type RESTORE to continue: " "$FILE"
read -r answer
[ "$answer" = "RESTORE" ] || { echo "aborted"; exit 1; }
$COMPOSE stop fiscal-hub
$COMPOSE exec -T postgres pg_restore -U gti -d gti_fiscal --clean --if-exists --no-owner "/backups/$FILE"
$COMPOSE up -d fiscal-hub # migrations run at start (alembic upgrade head)
echo "restore ok: $FILE"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env sh
# Restore the release-storage volume (backup.sh's *.tgz) into the Hub.
# DESTRUCTIVE for the current release binaries: asks for confirmation. The
# AgentRelease metadata (version/sha256/status) lives in Postgres, not here —
# restore that separately with restore.sh from a dump taken around the same time.
# ./restore_releases.sh ./backups/gti_fiscal_releases_20260923_224551.tgz
set -eu
[ $# -eq 1 ] || { echo "usage: $0 <releases_backup.tgz>" >&2; exit 2; }
FILE="$(basename "$1")"
COMPOSE="docker compose -f docker-compose.prod.yml --env-file .env"
# See backup.sh: BACKUP_DIR lives in .env, which --env-file only feeds to
# the container, not to this shell script.
if [ -f .env ]; then set -a; . ./.env; set +a; fi
[ -s "${BACKUP_DIR:-./backups}/$FILE" ] || { echo "backup not found in ${BACKUP_DIR:-./backups}: $FILE" >&2; exit 1; }
printf "This REPLACES the current release-storage contents with %s. Type RESTORE to continue: " "$FILE"
read -r answer
[ "$answer" = "RESTORE" ] || { echo "aborted"; exit 1; }
$COMPOSE exec -T fiscal-hub sh -c 'rm -rf /app/data/releases && mkdir -p /app/data/releases'
$COMPOSE exec -T fiscal-hub tar -C /app/data -xz < "${BACKUP_DIR:-./backups}/$FILE"
echo "restore ok: $FILE"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env sh
# Restrict the published Hub port so that only the reverse proxy host reaches it.
#
# HUB_BIND=10.0.10.117 HUB_PORT=8000 PROXY_IP=10.0.10.100 IFACE=ens18 sh restrict_hub_port.sh
#
# Docker-published ports never pass through UFW/INPUT: the packets are DNATed
# and forwarded, so the only supported place for this rule is DOCKER-USER.
# Matching on the conntrack *original* destination selects this published port
# (after DNAT the packet already points at the container); --ctdir ORIGINAL keeps
# the container's replies out of the DROP rule. Idempotent: safe to re-run.
# DOCKER-USER is not persisted across reboots — gti-fiscal-hub-port.service
# re-applies it every time docker.service starts.
set -eu
: "${HUB_BIND:?HUB_BIND is required}" "${PROXY_IP:?PROXY_IP is required}" "${IFACE:?IFACE is required}"
HUB_PORT="${HUB_PORT:-8000}"
MATCH="-i $IFACE -p tcp -m conntrack --ctdir ORIGINAL --ctorigdst $HUB_BIND --ctorigdstport $HUB_PORT"
iptables -N DOCKER-USER 2>/dev/null || true
while iptables -D DOCKER-USER $MATCH -s "$PROXY_IP" -j RETURN 2>/dev/null; do :; done
while iptables -D DOCKER-USER $MATCH -j DROP 2>/dev/null; do :; done
iptables -I DOCKER-USER 1 $MATCH -j DROP
iptables -I DOCKER-USER 1 $MATCH -s "$PROXY_IP" -j RETURN
iptables -S DOCKER-USER
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env sh
# Seed a NEW production Hub (already migrated: the Hub container ran `alembic upgrade head`) with the validated fiscal datasets.
# ./seed_datasets.sh gti_datasets_seed.dump
set -eu
[ $# -eq 1 ] || { echo "usage: $0 <gti_datasets_seed.dump>" >&2; exit 2; }
COMPOSE="docker compose -f docker-compose.prod.yml --env-file .env"
EXISTING="$($COMPOSE exec -T postgres psql -U gti -d gti_fiscal -At -c 'select count(*) from datasets')"
[ "$EXISTING" = "0" ] || { echo "refusing: datasets table is not empty ($EXISTING rows)" >&2; exit 1; }
$COMPOSE exec -T postgres pg_restore -U gti -d gti_fiscal --data-only --no-owner --disable-triggers < "$1"
$COMPOSE exec -T postgres psql -U gti -d gti_fiscal -At -c "select kind, status, record_count from datasets where status='ACTIVE' order by kind"