feat: Agent distribution with secure enrollment from the control panel
One universal Windows Agent package per version (agent_releases: version/ platform/architecture/sha256/status DRAFT|PUBLISHED|REVOKED; the binary lives on a local release-storage volume, never in PostgreSQL). An operator creates a pending installation (agent_enrollments) from Agents / Servidores -> + Nova instalação, then either downloads it directly or generates a one-time HTTPS link (short TTL, single use). Either path streams the package plus a freshly minted enrollment.json whose token is never written to storage in plaintext, only its SHA-256 hash. On the client host, gti-agent.ps1 install-auto verifies the bundle's SHA-256, refuses a non- HTTPS Hub, enrolls via POST /v1/agents/enroll (deleting the enrollment token immediately after, success or failure), writes an ACL-restricted .env with the newly issued permanent Agent token, and registers the Windows Service without starting it (Firebird stays unconfigured on purpose). Idempotent, replay-proof, rate-limited per IP, fully audited, never logs a secret. Migration 0022_agent_releases_enrollment (verified fresh-DB == upgrade-from-0021 by pg_dump diff). GTI_FISCAL_VERSION/AGENT_VERSION bumped to 1.1.0 as the single source of truth across code, packages, the release manifest and every published AgentRelease row. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gti-fiscal-agent"
|
||||
version = "1.0.0rc1"
|
||||
version = "1.1.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["httpx>=0.27,<1"]
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ class Config:
|
||||
self.requested_writes = requested
|
||||
self.firebird_writes = requested and self.fiscal_mode == "AUTO_APPLY"
|
||||
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
|
||||
self.agent_version = env.get("AGENT_VERSION", "1.0.0-rc1")
|
||||
self.agent_version = env.get("AGENT_VERSION", "1.1.0")
|
||||
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
|
||||
self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
|
||||
# Pilot write authorization: both must be explicitly set by the installer for
|
||||
@@ -172,7 +172,7 @@ def execute_job(job, config, executor, state, env):
|
||||
result_summary = {
|
||||
"coverage": coverage,
|
||||
"fiscal_mode": getattr(config, "fiscal_mode", None),
|
||||
"release_version": env.get("AGENT_VERSION") or "1.0.0-rc1",
|
||||
"release_version": env.get("AGENT_VERSION") or "1.1.0",
|
||||
"products_processed": summary["processed"],
|
||||
"writes_attempted": summary["writes_attempted"],
|
||||
"writes_succeeded": summary["writes_succeeded"],
|
||||
@@ -280,7 +280,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
|
||||
def enforce_startup_preflight(env):
|
||||
"""Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed."""
|
||||
from gti_fiscal_agent.preflight import run_preflight
|
||||
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.0.0-rc1")
|
||||
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.1.0")
|
||||
logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False))
|
||||
print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False))
|
||||
if report["PREFLIGHT"] != "PASS":
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
AGENT_VERSION_DEFAULT = "1.0.0-rc1"
|
||||
AGENT_VERSION_DEFAULT = "1.1.0"
|
||||
REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD")
|
||||
REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest")
|
||||
FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY")
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Agent distribution: releases + secure enrollment.
|
||||
|
||||
agent_releases catalogs published builds of the universal Windows Agent
|
||||
package (the binary itself lives in local release storage, never in
|
||||
Postgres). agent_enrollments tracks one pending-or-completed installation
|
||||
per row, carrying two distinct hashed, single-use, short-TTL secrets: a
|
||||
download token (the shareable package link) and an enrollment token
|
||||
(embedded in the downloaded bundle, consumed once by POST /v1/agents/enroll).
|
||||
Neither secret is ever stored in plaintext.
|
||||
|
||||
Revision ID: 0022_agent_releases_enrollment
|
||||
Revises: 0021_sso_federated_identity
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0022_agent_releases_enrollment"
|
||||
down_revision = "0021_sso_federated_identity"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"agent_releases",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("version", sa.String(32), nullable=False),
|
||||
sa.Column("platform", sa.String(16), nullable=False, server_default="windows"),
|
||||
sa.Column("architecture", sa.String(16), nullable=False, server_default="x64"),
|
||||
sa.Column("filename", sa.String(255), nullable=False),
|
||||
sa.Column("size_bytes", sa.Integer(), nullable=False),
|
||||
sa.Column("sha256", sa.String(64), nullable=False),
|
||||
sa.Column("storage_path", sa.String(512), nullable=False),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="DRAFT"),
|
||||
sa.Column("release_notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_by", sa.Integer(), sa.ForeignKey("admin_users.id"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint("version", "platform", "architecture", name="uq_agent_release_version"),
|
||||
)
|
||||
op.create_index("ix_agent_releases_version", "agent_releases", ["version"])
|
||||
op.create_index("ix_agent_releases_sha256", "agent_releases", ["sha256"])
|
||||
op.create_index("ix_agent_releases_status", "agent_releases", ["status"])
|
||||
|
||||
op.create_table(
|
||||
"agent_enrollments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("enrollment_uuid", sa.String(36), nullable=False),
|
||||
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
|
||||
sa.Column("display_name", sa.String(160), nullable=False),
|
||||
sa.Column("expected_hostname", sa.String(255), nullable=True),
|
||||
sa.Column("release_id", sa.Integer(), sa.ForeignKey("agent_releases.id"), nullable=False),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="PENDING"),
|
||||
sa.Column("created_by", sa.Integer(), sa.ForeignKey("admin_users.id"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("download_token_hash", sa.String(64), nullable=True),
|
||||
sa.Column("download_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("download_max_uses", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("download_uses", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("downloaded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("enrollment_token_hash", sa.String(64), nullable=True),
|
||||
sa.Column("enrollment_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("enrollment_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("machine_fingerprint_seen", sa.String(128), nullable=True),
|
||||
sa.Column("hostname_seen", sa.String(255), nullable=True),
|
||||
sa.Column("hostname_mismatch", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint("enrollment_uuid", name="uq_agent_enrollment_uuid"),
|
||||
)
|
||||
op.create_index("ix_agent_enrollments_uuid", "agent_enrollments", ["enrollment_uuid"])
|
||||
op.create_index("ix_agent_enrollments_tenant", "agent_enrollments", ["tenant_id"])
|
||||
op.create_index("ix_agent_enrollments_release", "agent_enrollments", ["release_id"])
|
||||
op.create_index("ix_agent_enrollments_status", "agent_enrollments", ["status"])
|
||||
op.create_index("ix_agent_enrollments_agent", "agent_enrollments", ["agent_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_agent_enrollments_agent", table_name="agent_enrollments")
|
||||
op.drop_index("ix_agent_enrollments_status", table_name="agent_enrollments")
|
||||
op.drop_index("ix_agent_enrollments_release", table_name="agent_enrollments")
|
||||
op.drop_index("ix_agent_enrollments_tenant", table_name="agent_enrollments")
|
||||
op.drop_index("ix_agent_enrollments_uuid", table_name="agent_enrollments")
|
||||
op.drop_table("agent_enrollments")
|
||||
op.drop_index("ix_agent_releases_status", table_name="agent_releases")
|
||||
op.drop_index("ix_agent_releases_sha256", table_name="agent_releases")
|
||||
op.drop_index("ix_agent_releases_version", table_name="agent_releases")
|
||||
op.drop_table("agent_releases")
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Public, pre-authentication routes for Agent distribution.
|
||||
|
||||
GET /dl/agent/{token} and POST /v1/agents/enroll are reachable by anyone —
|
||||
same trust level as GET /login. Every other Agent-distribution route
|
||||
(create pending installation, generate a link, direct authenticated
|
||||
download, list/revoke releases) is a control-panel page and lives in
|
||||
web.py, gated the same way as the rest of the panel (admin session + CSRF).
|
||||
|
||||
The enrollment token embedded in a downloaded bundle is generated fresh at
|
||||
download time and its plaintext is never written to storage — only its
|
||||
SHA-256 hash persists, exactly like the download token that gated getting
|
||||
it. Neither is ever the Agent's own permanent Bearer token, which is
|
||||
minted only once, inside a successful POST /v1/agents/enroll.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import config, enrollment, release_storage
|
||||
from .audit import record_audit
|
||||
from .auth import token_hash as agent_token_hash
|
||||
from .models import Agent, AgentEnrollment, AgentRelease, Installation
|
||||
from .rate_limit import blocked, record_attempt
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _aware(value: datetime | None) -> datetime | None:
|
||||
"""SQLite (used by the test suite) drops tzinfo on round-trip even for a
|
||||
DateTime(timezone=True) column; Postgres never does. Normalize either way
|
||||
before comparing against datetime.now(timezone.utc) — same pattern as
|
||||
human_auth.py's session-expiry check."""
|
||||
if value is None or value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def build_and_serve(session, enr: AgentEnrollment, request: Request, *, actor_admin_id: int | None = None) -> Response:
|
||||
"""Mint a fresh enrollment token, build the per-installation bundle and
|
||||
return it as a download response. Shared by the temp-link route and the
|
||||
admin's direct "Baixar Agent" button in web.py."""
|
||||
release = session.get(AgentRelease, enr.release_id)
|
||||
if release is None or release.status != "PUBLISHED":
|
||||
raise enrollment.EnrollmentRejected("RELEASE_UNAVAILABLE")
|
||||
path = release_storage.safe_path_for(release.storage_path)
|
||||
if not path.exists():
|
||||
raise enrollment.EnrollmentRejected("RELEASE_FILE_MISSING")
|
||||
token = enrollment.new_token()
|
||||
now = datetime.now(timezone.utc)
|
||||
enr.enrollment_token_hash = enrollment.token_digest(token)
|
||||
enr.enrollment_token_expires_at = now + timedelta(seconds=config.settings.agent_enrollment_ttl_seconds)
|
||||
enr.enrollment_used_at = None
|
||||
enr.downloaded_at = now
|
||||
if enr.status not in ("ENROLLED", "REVOKED"):
|
||||
enr.status = "DOWNLOADED"
|
||||
plan = enrollment.BundlePlan(hub_url=config.settings.base_url, enrollment_uuid=enr.enrollment_uuid, enrollment_token=token)
|
||||
data = enrollment.build_bundle(path.read_bytes(), plan)
|
||||
record_audit(session, admin_user_id=actor_admin_id, tenant_id=enr.tenant_id, action="PACKAGE_DOWNLOADED",
|
||||
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
|
||||
context={"ip": client_ip(request), "release_version": release.version, "release_id": release.id})
|
||||
session.commit()
|
||||
filename = f"GTIFiscalAgent-{release.version}-{enr.enrollment_uuid[:8]}.zip"
|
||||
return Response(content=data, media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def router(session_factory):
|
||||
routes = APIRouter()
|
||||
|
||||
@routes.get("/dl/agent/{token}")
|
||||
def download_by_link(token: str, request: Request):
|
||||
ip = client_ip(request)
|
||||
if blocked("download", ip):
|
||||
raise HTTPException(429, "too many attempts")
|
||||
with session_factory() as session:
|
||||
candidates = session.scalars(select(AgentEnrollment).where(AgentEnrollment.download_token_hash.is_not(None))).all()
|
||||
enr = next((c for c in candidates if enrollment.token_matches(token, c.download_token_hash)), None)
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
if enr is None:
|
||||
raise enrollment.EnrollmentRejected("INVALID_LINK")
|
||||
if enr.status == "REVOKED":
|
||||
raise enrollment.EnrollmentRejected("REVOKED")
|
||||
if not enr.download_token_expires_at or _aware(enr.download_token_expires_at) < now:
|
||||
raise enrollment.EnrollmentRejected("EXPIRED_LINK")
|
||||
if enr.download_uses >= enr.download_max_uses:
|
||||
raise enrollment.EnrollmentRejected("LINK_ALREADY_USED")
|
||||
enr.download_uses += 1
|
||||
if enr.download_uses >= enr.download_max_uses:
|
||||
enr.download_token_hash = None # the link's last remaining use is spent: it dies immediately
|
||||
return build_and_serve(session, enr, request)
|
||||
except enrollment.EnrollmentRejected as rejected:
|
||||
session.rollback()
|
||||
record_attempt("download", ip)
|
||||
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id if enr else None, action="DOWNLOAD_LINK_REJECTED",
|
||||
target_type="agent_enrollment", target_id=enr.enrollment_uuid if enr else "-",
|
||||
context={"ip": ip, "reason": rejected.reason}, severity="WARNING")
|
||||
session.commit()
|
||||
raise HTTPException(404, "invalid or expired link")
|
||||
|
||||
@routes.post("/v1/agents/enroll")
|
||||
async def enroll(request: Request):
|
||||
ip = client_ip(request)
|
||||
if blocked("enroll", ip):
|
||||
raise HTTPException(429, "too many attempts")
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
installation_id = str(payload.get("installation_id") or "")
|
||||
supplied_token = payload.get("enrollment_token")
|
||||
with session_factory() as session:
|
||||
enr = None
|
||||
try:
|
||||
if not enrollment.valid_uuid(installation_id):
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_id))
|
||||
if enr is None:
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
|
||||
if enr.status == "REVOKED":
|
||||
raise enrollment.EnrollmentRejected("REVOKED")
|
||||
if enr.status == "ENROLLED":
|
||||
raise enrollment.EnrollmentRejected("ALREADY_ENROLLED")
|
||||
now = datetime.now(timezone.utc)
|
||||
if not enr.enrollment_token_expires_at or _aware(enr.enrollment_token_expires_at) < now:
|
||||
raise enrollment.EnrollmentRejected("EXPIRED")
|
||||
if not enrollment.token_matches(supplied_token, enr.enrollment_token_hash):
|
||||
raise enrollment.EnrollmentRejected("INVALID_TOKEN")
|
||||
release = session.get(AgentRelease, enr.release_id)
|
||||
if release is None or release.status != "PUBLISHED":
|
||||
raise enrollment.EnrollmentRejected("RELEASE_UNAVAILABLE")
|
||||
|
||||
hostname = str(payload.get("hostname") or "")[:255]
|
||||
mismatch = bool(enr.expected_hostname) and bool(hostname) and hostname.lower() != enr.expected_hostname.lower()
|
||||
|
||||
plain_token = secrets.token_urlsafe(32)
|
||||
agent = Agent(tenant_id=enr.tenant_id, name=enr.display_name, token_hash=agent_token_hash(plain_token),
|
||||
enabled=True, agent_version=str(payload.get("agent_version") or "")[:32] or None)
|
||||
session.add(agent)
|
||||
session.flush()
|
||||
session.add(Installation(agent_id=agent.id, tenant_id=enr.tenant_id, installation_id=enr.enrollment_uuid,
|
||||
display_name=enr.display_name, hostname=hostname or None))
|
||||
|
||||
enr.status = "ENROLLED"
|
||||
enr.enrollment_used_at = now
|
||||
enr.enrollment_token_hash = None # replay-proof even inside its TTL window
|
||||
enr.hostname_seen = hostname or None
|
||||
enr.machine_fingerprint_seen = str(payload.get("machine_fingerprint") or "")[:128] or None
|
||||
enr.hostname_mismatch = mismatch
|
||||
enr.agent_id = agent.id
|
||||
|
||||
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id, action="ENROLLMENT_SUCCEEDED",
|
||||
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
|
||||
context={"ip": ip, "hostname": hostname, "hostname_mismatch": mismatch, "agent_id": agent.id})
|
||||
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id, action="AGENT_CREDENTIAL_ISSUED",
|
||||
target_type="agent", target_id=str(agent.id), context={"ip": ip, "installation": enr.enrollment_uuid})
|
||||
session.commit()
|
||||
return {"agent_id": agent.id, "agent_token": plain_token, "tenant_id": enr.tenant_id,
|
||||
"hub_url": config.settings.base_url, "hostname_mismatch": mismatch}
|
||||
except enrollment.EnrollmentRejected as rejected:
|
||||
session.rollback()
|
||||
record_attempt("enroll", ip)
|
||||
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id if enr else None, action="ENROLLMENT_REJECTED",
|
||||
target_type="agent_enrollment", target_id=installation_id or "-",
|
||||
context={"ip": ip, "reason": rejected.reason}, severity="WARNING")
|
||||
session.commit()
|
||||
raise HTTPException(401, rejected.reason)
|
||||
|
||||
return routes
|
||||
@@ -45,6 +45,13 @@ class Settings:
|
||||
sso_public_key: str = os.getenv("SSO_GERENCIADOR_CHAVE_PUBLICA", "")
|
||||
sso_start_url: str = os.getenv("SSO_START_URL", "")
|
||||
federated_session_seconds: int = int(os.getenv("FEDERATED_SESSION_SECONDS", "3600"))
|
||||
# ---- Agent distribution (central/app/agent_distribution.py) ----
|
||||
# Local, non-web-served directory holding the released Windows Agent zips.
|
||||
# Never inside PostgreSQL, never under central/static.
|
||||
agent_release_storage_path: str = os.getenv("AGENT_RELEASE_STORAGE_PATH", "./data/releases")
|
||||
agent_download_link_ttl_seconds: int = int(os.getenv("AGENT_DOWNLOAD_LINK_TTL_SECONDS", "1800"))
|
||||
agent_download_max_uses_default: int = int(os.getenv("AGENT_DOWNLOAD_MAX_USES_DEFAULT", "1"))
|
||||
agent_enrollment_ttl_seconds: int = int(os.getenv("AGENT_ENROLLMENT_TTL_SECONDS", "86400"))
|
||||
ncm_dataset_url: str = os.getenv("NCM_DATASET_URL", "https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json")
|
||||
cest_dataset_url: str = os.getenv("CEST_DATASET_URL", "https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18")
|
||||
dataset_storage_path: str = os.getenv("DATASET_STORAGE_PATH", "./data/datasets")
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Secrets and package bundling for Agent distribution.
|
||||
|
||||
Two independent secrets guard the distribution flow, both random(256 bits),
|
||||
single-use and short-TTL, both stored only as SHA-256 hashes:
|
||||
|
||||
- the *download* token: embedded in the shareable link an operator hands to
|
||||
whoever installs the Agent (or clicked directly, authenticated, from the
|
||||
panel);
|
||||
- the *enrollment* token: embedded inside the downloaded bundle as
|
||||
`enrollment.json`, consumed exactly once by `POST /v1/agents/enroll`.
|
||||
|
||||
Neither token is ever the Agent's own permanent Bearer token (issued only
|
||||
after a successful enrollment) and neither is ever logged or audited in
|
||||
plaintext.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class EnrollmentRejected(Exception):
|
||||
def __init__(self, reason: str):
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
"""256 bits of randomness, URL-safe — usable directly in a link or a JSON field."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def token_digest(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def token_matches(token: str | None, stored_hash: str | None) -> bool:
|
||||
if not token or not stored_hash:
|
||||
return False
|
||||
return hmac.compare_digest(token_digest(token), stored_hash)
|
||||
|
||||
|
||||
ENROLLMENT_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||
|
||||
|
||||
def valid_uuid(value: str | None) -> bool:
|
||||
return bool(value) and bool(ENROLLMENT_UUID_RE.match(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BundlePlan:
|
||||
hub_url: str
|
||||
enrollment_uuid: str
|
||||
enrollment_token: str
|
||||
enroll_path: str = "/v1/agents/enroll"
|
||||
|
||||
|
||||
def build_bundle(release_zip_bytes: bytes, plan: BundlePlan) -> bytes:
|
||||
"""Return a new zip = the release zip's entries plus one
|
||||
`<package-root>/enrollment.json` holding only temporary, single-use data.
|
||||
Never a permanent credential — that is issued only once enrollment succeeds."""
|
||||
with zipfile.ZipFile(io.BytesIO(release_zip_bytes)) as source:
|
||||
names = source.namelist()
|
||||
if not names:
|
||||
raise EnrollmentRejected("EMPTY_RELEASE_PACKAGE")
|
||||
root = names[0].split("/", 1)[0]
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dest:
|
||||
for name in names:
|
||||
dest.writestr(name, source.read(name))
|
||||
payload = {
|
||||
"installation_id": plan.enrollment_uuid,
|
||||
"enrollment_token": plan.enrollment_token,
|
||||
"hub_url": plan.hub_url,
|
||||
"enroll_path": plan.enroll_path,
|
||||
}
|
||||
dest.writestr(f"{root}/enrollment.json", json.dumps(payload, indent=1))
|
||||
return out.getvalue()
|
||||
@@ -8,6 +8,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from .config import settings
|
||||
from .db import SessionLocal, database_is_available
|
||||
from .api import router
|
||||
from .agent_distribution import router as agent_distribution_router
|
||||
from .control_api import router as control_api_router
|
||||
from .web import router as web_router
|
||||
from .version import GTI_FISCAL_VERSION
|
||||
@@ -37,6 +38,7 @@ def create_app(database_probe: Callable[[], bool] = database_is_available, sessi
|
||||
|
||||
app.include_router(router(session_factory, settings.agent_dev_token if bootstrap_token is None else bootstrap_token))
|
||||
app.include_router(control_api_router(session_factory))
|
||||
app.include_router(agent_distribution_router(session_factory))
|
||||
app.include_router(web_router(session_factory))
|
||||
app.mount("/static", StaticFiles(directory="central/static"), name="static")
|
||||
return app
|
||||
|
||||
@@ -366,6 +366,59 @@ class AuditEvent(Base):
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class AgentRelease(Base):
|
||||
"""A published build of the Windows Agent package. The universal binary/zip is
|
||||
shared by every installation; storage_path points at it on the Hub's local
|
||||
release storage (never inside PostgreSQL)."""
|
||||
__tablename__ = "agent_releases"
|
||||
__table_args__ = (UniqueConstraint("version", "platform", "architecture", name="uq_agent_release_version"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
version: Mapped[str] = mapped_column(String(32), index=True)
|
||||
platform: Mapped[str] = mapped_column(String(16), default="windows", server_default="windows")
|
||||
architecture: Mapped[str] = mapped_column(String(16), default="x64", server_default="x64")
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
size_bytes: Mapped[int] = mapped_column(Integer)
|
||||
sha256: Mapped[str] = mapped_column(String(64), index=True)
|
||||
storage_path: Mapped[str] = mapped_column(String(512))
|
||||
status: Mapped[str] = mapped_column(String(16), default="DRAFT", server_default="DRAFT", index=True) # DRAFT | PUBLISHED | REVOKED
|
||||
release_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class AgentEnrollment(Base):
|
||||
"""A pending (or completed) Agent installation: created by an operator in the
|
||||
control panel before any real Agent exists. Carries two distinct, hashed,
|
||||
single-use, short-TTL secrets — a download token (for the shareable package
|
||||
link) and an enrollment token (embedded in the bundle, consumed once by
|
||||
POST /v1/agents/enroll). Never stores either secret in plaintext."""
|
||||
__tablename__ = "agent_enrollments"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
enrollment_uuid: Mapped[str] = mapped_column(String(36), unique=True, index=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(160))
|
||||
expected_hostname: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
release_id: Mapped[int] = mapped_column(ForeignKey("agent_releases.id"), index=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="PENDING", server_default="PENDING", index=True)
|
||||
# PENDING | LINK_CREATED | DOWNLOADED | ENROLLED | EXPIRED | REVOKED | FAILED
|
||||
created_by: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
download_token_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
download_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
download_max_uses: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
|
||||
download_uses: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
downloaded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
enrollment_token_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
enrollment_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
enrollment_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
machine_fingerprint_seen: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
hostname_seen: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
hostname_mismatch: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
agent_id: Mapped[int | None] = mapped_column(ForeignKey("agents.id"), nullable=True, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""In-memory per-IP rate limiting for pre-authentication routes.
|
||||
|
||||
Same spirit as painel-clientes-web's `app/rate_limit.py`: a single-process
|
||||
in-memory counter, fine because the Hub runs as one container behind a
|
||||
trusted reverse proxy (FORWARDED_ALLOW_IPS), never multiple replicas behind
|
||||
a shared load balancer. A process restart resets the history — accepted,
|
||||
same tradeoff as that panel's SESSION_SECRET.
|
||||
|
||||
Used for the two public, pre-authentication Agent-distribution routes
|
||||
(GET /dl/agent/{token}, POST /v1/agents/enroll): invalid attempts count
|
||||
against the caller's IP; a legitimate Agent's own successful traffic never
|
||||
gets counted, so it can never push other installations over the limit.
|
||||
"""
|
||||
import time
|
||||
|
||||
MAX_ATTEMPTS = 10
|
||||
WINDOW_SECONDS = 5 * 60
|
||||
|
||||
_attempts: dict[tuple[str, str], list[float]] = {}
|
||||
|
||||
|
||||
def _recent(bucket: str, origin: str, now: float) -> list[float]:
|
||||
cutoff = now - WINDOW_SECONDS
|
||||
return [t for t in _attempts.get((bucket, origin), []) if t > cutoff]
|
||||
|
||||
|
||||
def blocked(bucket: str, origin: str, now: float | None = None) -> bool:
|
||||
moment = now if now is not None else time.time()
|
||||
return len(_recent(bucket, origin, moment)) >= MAX_ATTEMPTS
|
||||
|
||||
|
||||
def record_attempt(bucket: str, origin: str, now: float | None = None) -> None:
|
||||
moment = now if now is not None else time.time()
|
||||
recent = _recent(bucket, origin, moment)
|
||||
recent.append(moment)
|
||||
_attempts[(bucket, origin)] = recent
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Local, non-web-served storage for published Agent release zips.
|
||||
|
||||
Binaries never live in PostgreSQL — only their metadata (AgentRelease rows)
|
||||
do. The storage root is a plain directory outside `central/static`, so it is
|
||||
never directly reachable by a URL; every download goes through the
|
||||
authenticated or token-gated routes in agent_distribution.py.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from . import config
|
||||
|
||||
_SAFE_COMPONENT = __import__("re").compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def storage_root() -> Path:
|
||||
root = Path(config.settings.agent_release_storage_path)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def release_filename(version: str, platform: str, architecture: str) -> str:
|
||||
return f"GTIFiscalAgent-{version}-{platform}-{architecture}.zip"
|
||||
|
||||
|
||||
def safe_path_for(storage_path: str) -> Path:
|
||||
"""Resolve a release's storage_path against the root, refusing anything
|
||||
that could escape it (path traversal). storage_path is DB-controlled
|
||||
(never derived from an HTTP request), but this stays fail-closed anyway."""
|
||||
if not storage_path or not _SAFE_COMPONENT.match(storage_path):
|
||||
raise ValueError(f"unsafe release storage_path: {storage_path!r}")
|
||||
root = storage_root().resolve()
|
||||
candidate = (root / storage_path).resolve()
|
||||
if candidate.parent != root:
|
||||
raise ValueError(f"release storage_path escapes storage root: {storage_path!r}")
|
||||
return candidate
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Single source of truth for the release version (RC1). Fiscal semantics are versioned separately
|
||||
(ENGINE_VERSION/DECISION_VERSION in classification.py, DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump."""
|
||||
GTI_FISCAL_VERSION = "1.0.0-rc1"
|
||||
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x talks to any Hub 1.0.x
|
||||
"""Single source of truth for the release version. Also what the Agent Windows
|
||||
package is versioned as (scripts/build_agent_package.py reads release/VERSION,
|
||||
kept identical to this) and what an AgentRelease row's `version` column holds
|
||||
once published — never a second, independent version number. Fiscal semantics
|
||||
are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py,
|
||||
DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump."""
|
||||
GTI_FISCAL_VERSION = "1.1.0"
|
||||
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x
|
||||
|
||||
+143
-3
@@ -1,7 +1,8 @@
|
||||
"""Server-rendered operator pages for the GTI Fiscal control panel."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
@@ -9,14 +10,14 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import config, sso
|
||||
from . import agent_distribution, config, enrollment, sso
|
||||
from .config import settings
|
||||
from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context
|
||||
from .context_keys import ALLOWED_KEYS, ENUMS
|
||||
from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, list_blockers, list_installations, list_jobs, list_panel_events
|
||||
from .human_auth import create_session, current_session, require_csrf, verify_password
|
||||
from .panel_presentation import format_ui_datetime
|
||||
from .models import AdminUser, Agent, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant
|
||||
from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant
|
||||
from .jobs import create_job
|
||||
from .audit import record_audit
|
||||
|
||||
@@ -207,6 +208,145 @@ def router(session_factory):
|
||||
return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/releases", response_class=HTMLResponse)
|
||||
def agent_releases(request: Request):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
releases = session.scalars(select(AgentRelease).order_by(AgentRelease.created_at.desc())).all()
|
||||
counts = {}
|
||||
for release_id, in session.execute(select(AgentEnrollment.release_id)).all():
|
||||
counts[release_id] = counts.get(release_id, 0) + 1
|
||||
return render(request, "agent_releases.html", {"releases": releases, "installation_counts": counts, "auth": auth_data})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/releases/{release_id}/revoke")
|
||||
async def revoke_release(request: Request, release_id: int):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
values = await form_values(request); web_session, admin = auth_data
|
||||
require_csrf(web_session, values.get("csrf_token"))
|
||||
release = session.get(AgentRelease, release_id)
|
||||
if not release: return HTMLResponse("Release not found", status_code=404)
|
||||
release.status = "REVOKED"
|
||||
record_audit(session, admin_user_id=admin.id, action="AGENT_RELEASE_REVOKED", target_type="agent_release", target_id=release.version, context={"platform": release.platform, "architecture": release.architecture})
|
||||
session.commit()
|
||||
return RedirectResponse("/agents/releases", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/new", response_class=HTMLResponse)
|
||||
def new_installation_form(request: Request):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all()
|
||||
releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all()
|
||||
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": None})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/new")
|
||||
async def create_pending_installation(request: Request):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
values = await form_values(request); web_session, admin = auth_data
|
||||
require_csrf(web_session, values.get("csrf_token"))
|
||||
tenant_id = values.get("tenant_id")
|
||||
display_name = (values.get("display_name") or "").strip()[:160]
|
||||
expected_hostname = (values.get("expected_hostname") or "").strip()[:255] or None
|
||||
release_id = values.get("release_id")
|
||||
errors = []
|
||||
tenant = session.get(Tenant, int(tenant_id)) if tenant_id and tenant_id.isdigit() else None
|
||||
release = session.get(AgentRelease, int(release_id)) if release_id and release_id.isdigit() else None
|
||||
if not tenant: errors.append("Selecione um cliente válido.")
|
||||
if not display_name: errors.append("Informe um nome para a instalação.")
|
||||
if not release or release.status != "PUBLISHED": errors.append("Selecione uma versão publicada do Agent.")
|
||||
if errors:
|
||||
tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all()
|
||||
releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all()
|
||||
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": " ".join(errors)}, 400)
|
||||
enrollment_uuid = str(uuid.uuid4())
|
||||
enr = AgentEnrollment(enrollment_uuid=enrollment_uuid, tenant_id=tenant.id, display_name=display_name, expected_hostname=expected_hostname, release_id=release.id, status="PENDING", created_by=admin.id)
|
||||
session.add(enr)
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=tenant.id, action="INSTALLATION_CREATED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"display_name": display_name, "release_version": release.version})
|
||||
session.commit()
|
||||
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
def _pending_or_404(session, enrollment_uuid):
|
||||
return session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == enrollment_uuid))
|
||||
|
||||
@routes.get("/agents/pending/{enrollment_uuid}", response_class=HTMLResponse)
|
||||
def pending_installation_detail(request: Request, enrollment_uuid: str, link: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
enr = _pending_or_404(session, enrollment_uuid)
|
||||
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
||||
release = session.get(AgentRelease, enr.release_id)
|
||||
tenant = session.get(Tenant, enr.tenant_id)
|
||||
installed_agent = session.get(Agent, enr.agent_id) if enr.agent_id else None
|
||||
return render(request, "agent_pending_detail.html", {"enr": enr, "release": release, "tenant": tenant, "installed_agent": installed_agent, "download_link": link, "auth": auth_data})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/pending/{enrollment_uuid}/link")
|
||||
async def create_download_link(request: Request, enrollment_uuid: str):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
values = await form_values(request); web_session, admin = auth_data
|
||||
require_csrf(web_session, values.get("csrf_token"))
|
||||
enr = _pending_or_404(session, enrollment_uuid)
|
||||
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
||||
if enr.status in ("ENROLLED", "REVOKED"): return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
|
||||
token = enrollment.new_token()
|
||||
enr.download_token_hash = enrollment.token_digest(token)
|
||||
enr.download_token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=config.settings.agent_download_link_ttl_seconds)
|
||||
enr.download_max_uses = config.settings.agent_download_max_uses_default
|
||||
enr.download_uses = 0
|
||||
enr.status = "LINK_CREATED"
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="DOWNLOAD_LINK_CREATED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"ttl_seconds": config.settings.agent_download_link_ttl_seconds, "ip": client_ip(request)})
|
||||
session.commit()
|
||||
url = f"{config.settings.base_url}/dl/agent/{token}"
|
||||
return RedirectResponse(f"/agents/pending/{enrollment_uuid}?link={url}", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/pending/{enrollment_uuid}/download")
|
||||
def direct_download(request: Request, enrollment_uuid: str):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
web_session, admin = auth_data
|
||||
enr = _pending_or_404(session, enrollment_uuid)
|
||||
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
||||
if enr.status == "REVOKED": return HTMLResponse("Installation revoked", status_code=409)
|
||||
try:
|
||||
return agent_distribution.build_and_serve(session, enr, request, actor_admin_id=admin.id)
|
||||
except enrollment.EnrollmentRejected as rejected:
|
||||
return HTMLResponse(f"Download indisponível: {rejected.reason}", status_code=409)
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/pending/{enrollment_uuid}/revoke")
|
||||
async def revoke_pending_installation(request: Request, enrollment_uuid: str):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
values = await form_values(request); web_session, admin = auth_data
|
||||
require_csrf(web_session, values.get("csrf_token"))
|
||||
enr = _pending_or_404(session, enrollment_uuid)
|
||||
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
||||
enr.status = "REVOKED"
|
||||
enr.download_token_hash = None
|
||||
enr.enrollment_token_hash = None
|
||||
if enr.agent_id:
|
||||
agent = session.get(Agent, enr.agent_id)
|
||||
if agent: agent.enabled = False
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"had_agent": bool(enr.agent_id)})
|
||||
session.commit()
|
||||
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/blockers", response_class=HTMLResponse)
|
||||
def blockers(request: Request, page: int = 1, client: str | None = None, installation: str | None = None, field: str | None = None, blocker_type: str | None = None, status: str = "OPEN", age: str | None = None, search: str | None = None, agent_id: int | None = None, product_code: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gti-fiscal-hub"
|
||||
version = "1.0.0rc1"
|
||||
version = "1.1.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<1",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Administrative CLI: publish a built Agent package (scripts/build_agent_package.py
|
||||
output) as an AgentRelease, ready to be downloaded/enrolled from the panel.
|
||||
|
||||
Like register_agent.py, this is a one-time, operator-run action against
|
||||
DATABASE_URL directly — never a browser upload endpoint, so the release
|
||||
binary never has to pass through an HTTP request body. The zip is copied
|
||||
into the Hub's local release storage (AGENT_RELEASE_STORAGE_PATH), never
|
||||
into PostgreSQL; only its metadata (version, sha256, size, path) is a row.
|
||||
|
||||
Usage:
|
||||
python central/scripts/publish_agent_release.py --zip dist/GTIFiscalAgent-1.0.0.zip \\
|
||||
--notes "First distributable build with secure enrollment"
|
||||
|
||||
Refuses to publish the same version+platform+architecture twice with a
|
||||
different sha256 (an accidental respin must get a new version). Publishing
|
||||
the exact same bytes again is a no-op (idempotent).
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "central"))
|
||||
|
||||
from sqlalchemy.orm import Session # noqa: E402
|
||||
|
||||
from app import release_storage # noqa: E402
|
||||
from app.db import engine # noqa: E402
|
||||
from app.models import AgentRelease # noqa: E402
|
||||
|
||||
|
||||
def publish(session, zip_path: Path, *, platform="windows", architecture="x64", notes=None, created_by=None, version=None):
|
||||
data = zip_path.read_bytes()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
version = version or _version_from_manifest(zip_path)
|
||||
existing = session.query(AgentRelease).filter_by(version=version, platform=platform, architecture=architecture).one_or_none()
|
||||
if existing:
|
||||
if existing.sha256 == digest:
|
||||
return existing, False # identical content already published: idempotent no-op
|
||||
raise SystemExit(f"refused: {version}/{platform}/{architecture} is already published with a different sha256 "
|
||||
f"({existing.sha256[:12]}... != {digest[:12]}...) — bump the version instead of overwriting it")
|
||||
filename = release_storage.release_filename(version, platform, architecture)
|
||||
dest = release_storage.storage_root() / filename
|
||||
dest.write_bytes(data)
|
||||
release = AgentRelease(version=version, platform=platform, architecture=architecture, filename=filename,
|
||||
size_bytes=len(data), sha256=digest, storage_path=filename, status="PUBLISHED",
|
||||
release_notes=notes, created_by=created_by)
|
||||
session.add(release)
|
||||
session.commit()
|
||||
return release, True
|
||||
|
||||
|
||||
def _version_from_manifest(zip_path: Path) -> str:
|
||||
# The package name is `GTIFiscalAgent-<version>.zip`; avoids depending on
|
||||
# zip internals just to learn the version this script itself is publishing.
|
||||
stem = zip_path.stem
|
||||
prefix = "GTIFiscalAgent-"
|
||||
if not stem.startswith(prefix):
|
||||
raise SystemExit(f"cannot infer version from filename {zip_path.name!r} — pass --version explicitly")
|
||||
return stem[len(prefix):]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--zip", required=True, type=Path, help="Path to the built package zip (scripts/build_agent_package.py output)")
|
||||
parser.add_argument("--version", default=None, help="Override the version instead of inferring it from the filename")
|
||||
parser.add_argument("--platform", default="windows")
|
||||
parser.add_argument("--architecture", default="x64")
|
||||
parser.add_argument("--notes", default=None, help="Release notes shown in the panel")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.zip.exists():
|
||||
raise SystemExit(f"not found: {args.zip}")
|
||||
|
||||
with Session(engine) as session:
|
||||
release, created = publish(session, args.zip, platform=args.platform, architecture=args.architecture, notes=args.notes, version=args.version)
|
||||
print(f"{'published' if created else 'already published (idempotent)'}: version={release.version} "
|
||||
f"platform={release.platform} architecture={release.architecture} sha256={release.sha256} size_bytes={release.size_bytes}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Nova instalação | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><p class="eyebrow">DISTRIBUIÇÃO DO AGENT</p><h1>Nova instalação</h1><p class="muted">Cria uma instalação pendente; o pacote e o vínculo de enrollment só existem depois de gerados aqui.</p></div><div><a href="/agents" class="secondary">Voltar</a></div></div>
|
||||
<section class="panel" style="max-width:560px">
|
||||
{% if error %}<p class="error" role="alert">{{ error }}</p>{% endif %}
|
||||
{% if not releases %}
|
||||
<p class="muted">Nenhuma versão do Agent está publicada ainda. Publique uma release primeiro (<code>python central/scripts/publish_agent_release.py</code>) e ela aparecerá aqui.</p>
|
||||
{% else %}
|
||||
<form method="post" action="/agents/new">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Cliente
|
||||
<select name="tenant_id" required>
|
||||
<option value="">Selecione</option>
|
||||
{% for tenant in tenants %}<option value="{{ tenant.id }}">{{ tenant.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Nome da instalação
|
||||
<input name="display_name" placeholder="ex. Servidor Principal" maxlength="160" required>
|
||||
</label>
|
||||
<label>Hostname esperado (opcional)
|
||||
<input name="expected_hostname" placeholder="ex. SRV-CONSTRUCAO" maxlength="255">
|
||||
</label>
|
||||
<label>Versão do Agent
|
||||
<select name="release_id" required>
|
||||
{% for release in releases %}<option value="{{ release.id }}" {% if loop.first %}selected{% endif %}>{{ release.version }} ({{ release.platform }}/{{ release.architecture }})</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p><button type="submit">Criar instalação</button></p>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ enr.display_name }} | Instalação | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="detail-head"><div><p class="eyebrow">DISTRIBUIÇÃO DO AGENT</p><h1>{{ enr.display_name }}</h1><p class="muted">{{ tenant.name if tenant else "Cliente não encontrado" }} · release {{ release.version if release else "?" }}</p></div><div><span class="badge">{{ enr.status }}</span></div></div>
|
||||
|
||||
{% if download_link %}
|
||||
<section class="panel">
|
||||
<h2>Link temporário gerado</h2>
|
||||
<p class="muted">Mostrado uma única vez, uso único, expira em pouco tempo. Copie agora — ele não pode ser recuperado depois.</p>
|
||||
<p><code style="word-break:break-all">{{ download_link }}</code></p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Detalhes</h2>
|
||||
<table>
|
||||
<tr><th>Cliente</th><td>{{ tenant.name if tenant else "-" }}</td></tr>
|
||||
<tr><th>Nome</th><td>{{ enr.display_name }}</td></tr>
|
||||
<tr><th>Hostname esperado</th><td>{{ enr.expected_hostname or "não informado" }}</td></tr>
|
||||
<tr><th>Versão do Agent</th><td>{{ release.version if release else "-" }} ({{ release.platform if release else "" }}/{{ release.architecture if release else "" }})</td></tr>
|
||||
<tr><th>Status</th><td><span class="badge">{{ enr.status }}</span>{% if enr.hostname_mismatch %} <span class="badge critical">hostname divergente</span>{% endif %}</td></tr>
|
||||
<tr><th>Criada em</th><td>{{ enr.created_at }}</td></tr>
|
||||
<tr><th>Baixada em</th><td>{{ enr.downloaded_at or "ainda não" }}</td></tr>
|
||||
<tr><th>Hostname visto</th><td>{{ enr.hostname_seen or "-" }}</td></tr>
|
||||
{% if installed_agent %}
|
||||
<tr><th>Agent</th><td><a href="/agents/{{ installed_agent.id }}">#{{ installed_agent.id }}</a> · {{ "ativo" if installed_agent.enabled else "desativado" }} · último heartbeat: {{ installed_agent.last_seen_at or "nenhum ainda" }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Ações</h2>
|
||||
{% if enr.status not in ("ENROLLED", "REVOKED") %}
|
||||
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/link" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Gerar link temporário</button>
|
||||
</form>
|
||||
<a href="/agents/pending/{{ enr.enrollment_uuid }}/download" class="botao">Baixar Agent agora</a>
|
||||
{% endif %}
|
||||
{% if enr.status != "REVOKED" %}
|
||||
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/revoke" style="display:inline" onsubmit="return confirm('Revogar esta instalação? Links e o Agent (se já ativo) deixam de funcionar.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary">Revogar</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if enr.status == "ENROLLED" and installed_agent %}
|
||||
<a href="/agents/{{ installed_agent.id }}" class="secondary">Ver Agent</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Releases do Agent | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><p class="eyebrow">DISTRIBUIÇÃO DO AGENT</p><h1>Versões do Agent</h1><p class="muted">O binário é universal por versão; publicação é feita por script (<code>central/scripts/publish_agent_release.py</code>), nunca por upload no navegador.</p></div><div><a href="/agents/new" class="botao">+ Nova instalação</a> <a href="/agents" class="secondary">Voltar</a></div></div>
|
||||
<div class="panel table-wrap">
|
||||
<table>
|
||||
<caption>Releases catalogadas</caption>
|
||||
<thead><tr><th>Versão</th><th>Plataforma</th><th>Status</th><th>SHA-256</th><th>Tamanho</th><th>Publicada em</th><th>Instalações</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for release in releases %}
|
||||
<tr>
|
||||
<td><b>{{ release.version }}</b></td>
|
||||
<td>{{ release.platform }}/{{ release.architecture }}</td>
|
||||
<td><span class="badge {{ 'critical' if release.status == 'REVOKED' else '' }}">{{ release.status }}</span></td>
|
||||
<td><code>{{ release.sha256[:12] }}…</code></td>
|
||||
<td>{{ "%.1f"|format(release.size_bytes / 1048576) }} MB</td>
|
||||
<td>{{ release.published_at or "não publicada" }}</td>
|
||||
<td>{{ installation_counts.get(release.id, 0) }}</td>
|
||||
<td>
|
||||
{% if release.status == "PUBLISHED" %}
|
||||
<form method="post" action="/agents/releases/{{ release.id }}/revoke" onsubmit="return confirm('Revogar a release {{ release.version }}? Nenhum novo download poderá usá-la.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary">Revogar</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="empty-state">Nenhuma release publicada ainda.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,7 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Agents | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><p class="eyebrow">INVENTÁRIO OPERACIONAL</p><h1>Agents / Servidores</h1><p class="muted">Identidade do cliente primeiro; IDs técnicos ficam no detalhe.</p></div><form class="filters" method="get"><label>Cliente, instalação ou hostname<input name="search" value="{{ search }}"></label><label>Saúde técnica<select name="health"><option value="">Todos</option>{% for value in ["ONLINE","DEGRADED","OFFLINE","FAILED","UNKNOWN"] %}<option value="{{ value }}" {% if health_filter==value %}selected{% endif %}>{{ value|title }}</option>{% endfor %}</select></label><label>Ordenação<select name="sort"><option value="display_name" {% if sort=="display_name" %}selected{% endif %}>Nome de exibição</option><option value="worst_coverage" {% if sort=="worst_coverage" %}selected{% endif %}>Pior cobertura fiscal</option></select></label><button class="secondary" type="submit">Filtrar</button></form></div>
|
||||
<div class="page-heading"><div><p class="eyebrow">INVENTÁRIO OPERACIONAL</p><h1>Agents / Servidores</h1><p class="muted">Identidade do cliente primeiro; IDs técnicos ficam no detalhe.</p></div><div><a href="/agents/new" class="botao">+ Nova instalação</a> <a href="/agents/releases" class="secondary">Releases</a></div></div>
|
||||
<div class="page-heading"><form class="filters" method="get"><label>Cliente, instalação ou hostname<input name="search" value="{{ search }}"></label><label>Saúde técnica<select name="health"><option value="">Todos</option>{% for value in ["ONLINE","DEGRADED","OFFLINE","FAILED","UNKNOWN"] %}<option value="{{ value }}" {% if health_filter==value %}selected{% endif %}>{{ value|title }}</option>{% endfor %}</select></label><label>Ordenação<select name="sort"><option value="display_name" {% if sort=="display_name" %}selected{% endif %}>Nome de exibição</option><option value="worst_coverage" {% if sort=="worst_coverage" %}selected{% endif %}>Pior cobertura fiscal</option></select></label><button class="secondary" type="submit">Filtrar</button></form></div>
|
||||
<div class="panel table-wrap"><table><caption>Instalações monitoradas</caption><thead><tr><th>Cliente</th><th>Nome de exibição</th><th>Hostname</th><th>Saúde técnica</th><th>Status fiscal</th><th>Cobertura fiscal</th><th>Produtos</th><th>Versão</th><th>Último heartbeat</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td><a href="/agents/{{ item.agent_id }}"><b>{{ item.client or "Cliente não identificado" }}</b></a></td><td>{{ item.display_name or "Não informado" }}</td><td>{{ item.hostname or "Não informado" }}</td><td><span class="badge {{ item.technical_health|lower }}">Saúde técnica: {{ item.technical_health_label }}</span></td><td><span class="badge fiscal-{{ item.fiscal_status|lower }}">Status fiscal: {{ item.fiscal_status_label }}</span><small>{{ item.fiscally_complete }} completos · {{ item.safe_blocked }} bloqueados com segurança · {{ item.failed_products }} falhas · {{ item.unclassified_products }} não classificados</small>{% if not item.coverage_invariant_pass %}<small class="error">Inconsistência: {{ item.coverage_overcount_products }} acima do total{% if item.coverage_invalid_counts %}; contagens negativas{% endif %}</small>{% endif %}</td><td><strong>{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%</strong><small>{{ item.fiscally_complete }} / {{ item.total_products }} completos</small></td><td>{{ item.total_products }}<small>{{ item.failed_products }} falhas técnicas</small></td><td>{{ item.agent_version_display }}</td><td title="{{ item.last_seen_at }}">{{ item.last_seen_display }}<small>{{ item.last_seen_age }}</small></td></tr>{% else %}<tr><td colspan="9" class="empty-state">Nenhuma instalação encontrada.</td></tr>{% endfor %}</tbody></table></div>
|
||||
<nav class="pagination" aria-label="Paginação de agents">{% if result.page > 1 %}<a href="/agents?page={{ result.page - 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} instalações</span>{% if result.page * result.page_size < result.total %}<a href="/agents?page={{ result.page + 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Próxima</a>{% endif %}</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Agent distribution: releases + secure enrollment.
|
||||
|
||||
Covers central/app/agent_distribution.py, enrollment.py, release_storage.py,
|
||||
rate_limit.py, scripts/publish_agent_release.py and the new admin pages in
|
||||
web.py. Security tests mirror the mission's checklist: expired/revoked/
|
||||
second-use/tampered download links, expired/replayed/wrong-installation/
|
||||
already-enrolled enrollment attempts, path traversal, rate limiting, and
|
||||
that no secret ever reaches the audit log.
|
||||
"""
|
||||
import dataclasses
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import config, enrollment, rate_limit, release_storage
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Agent, AgentEnrollment, AgentRelease, AuditEvent, Installation, Tenant
|
||||
from central.scripts.publish_agent_release import publish
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
|
||||
VERSION = "1.2.3"
|
||||
|
||||
|
||||
def make_release_zip(version=VERSION) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
z.writestr(f"GTIFiscalAgent-{version}/gti-agent.ps1", "# fake install script\n")
|
||||
z.writestr(f"GTIFiscalAgent-{version}/VERSION", version)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def write_temp_zip(data: bytes, version=VERSION) -> Path:
|
||||
path = Path(tempfile.gettempdir()) / f"GTIFiscalAgent-{version}-{id(data)}.zip"
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_rate_limit():
|
||||
rate_limit._attempts.clear()
|
||||
yield
|
||||
rate_limit._attempts.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub(tmp_path, monkeypatch):
|
||||
replaced = dataclasses.replace(
|
||||
config.settings,
|
||||
agent_release_storage_path=str(tmp_path / "releases"),
|
||||
agent_download_link_ttl_seconds=1800,
|
||||
agent_download_max_uses_default=1,
|
||||
agent_enrollment_ttl_seconds=86400,
|
||||
base_url="https://gtifiscal.example.gti",
|
||||
web_cookie_secure=False,
|
||||
)
|
||||
monkeypatch.setattr(config, "settings", replaced)
|
||||
# publish_agent_release.py is written to also run standalone (`python
|
||||
# central/scripts/publish_agent_release.py`), so it imports via a
|
||||
# sys.path-inserted "app" package (same convention as register_agent.py)
|
||||
# rather than "central.app" — a second, independent copy of every module
|
||||
# it touches, including config. Patch that copy's settings too, or the
|
||||
# release it publishes lands outside this test's tmp_path.
|
||||
if "app.config" in sys.modules:
|
||||
monkeypatch.setattr(sys.modules["app.config"], "settings", replaced)
|
||||
factory = setup_db()
|
||||
seed(factory) # tenant "Acme" + Installation "srv-01" + admin "operator"/"a-secure-password"
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
|
||||
return client, factory
|
||||
|
||||
|
||||
def login(client):
|
||||
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
|
||||
|
||||
def csrf(client):
|
||||
return client.cookies.get("gti_csrf", "")
|
||||
|
||||
|
||||
def publish_release(factory, version=VERSION):
|
||||
with factory() as session:
|
||||
release, _created = publish(session, write_temp_zip(make_release_zip(version), version), version=version)
|
||||
return release.id
|
||||
|
||||
|
||||
def tenant_id(factory):
|
||||
with factory() as session:
|
||||
return session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
|
||||
|
||||
|
||||
def audits(factory):
|
||||
with factory() as session:
|
||||
return session.scalars(select(AuditEvent)).all()
|
||||
|
||||
|
||||
def bundle_enrollment_fields(content: bytes) -> dict:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as z:
|
||||
[entry] = [n for n in z.namelist() if n.endswith("enrollment.json")]
|
||||
return json.loads(z.read(entry))
|
||||
|
||||
|
||||
# ---------- publish_agent_release.py ----------
|
||||
|
||||
def test_publish_is_idempotent_and_rejects_hash_conflict(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
with factory() as session:
|
||||
zip_bytes = make_release_zip("9.9.9")
|
||||
release, created = publish(session, write_temp_zip(zip_bytes, "9.9.9"), version="9.9.9")
|
||||
assert created and release.status == "PUBLISHED" and release.version == "9.9.9"
|
||||
release2, created2 = publish(session, write_temp_zip(zip_bytes, "9.9.9"), version="9.9.9")
|
||||
assert not created2 and release2.id == release.id # identical bytes: no-op
|
||||
with factory() as session:
|
||||
with pytest.raises(SystemExit):
|
||||
publish(session, write_temp_zip(make_release_zip("9.9.9") + b"\x00", "9.9.9"), version="9.9.9")
|
||||
|
||||
|
||||
def test_published_release_binary_never_stored_in_postgres(hub):
|
||||
_client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
with factory() as session:
|
||||
release = session.get(AgentRelease, release_id)
|
||||
assert release.storage_path == Path(release.storage_path).name # a bare filename, never a path with directories
|
||||
path = release_storage.safe_path_for(release.storage_path)
|
||||
assert path.exists() and path.read_bytes() # binary lives on disk, outside the DB
|
||||
|
||||
|
||||
# ---------- path traversal ----------
|
||||
|
||||
def test_release_storage_path_traversal_impossible(hub):
|
||||
for hostile in ("../../etc/passwd", "sub/dir.zip", "/etc/passwd", "..\\..\\windows\\system32", ""):
|
||||
with pytest.raises(ValueError):
|
||||
release_storage.safe_path_for(hostile)
|
||||
|
||||
|
||||
# ---------- full happy path: create -> link -> download -> enroll ----------
|
||||
|
||||
def test_full_installation_flow_creates_a_working_agent(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
login(client)
|
||||
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Servidor Principal", "expected_hostname": "SRV-UNIAO",
|
||||
"release_id": release_id}, follow_redirects=False)
|
||||
assert created.status_code == 303
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
|
||||
linked = client.post(f"/agents/pending/{installation_uuid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
assert linked.status_code == 303
|
||||
download_url = linked.headers["location"].split("link=", 1)[1]
|
||||
download_token = download_url.rsplit("/", 1)[-1]
|
||||
|
||||
# A technician on the client machine never has an admin session.
|
||||
anon = TestClient(client.app)
|
||||
downloaded = anon.get(f"/dl/agent/{download_token}")
|
||||
assert downloaded.status_code == 200
|
||||
assert downloaded.headers["content-type"] == "application/octet-stream"
|
||||
assert "attachment" in downloaded.headers["content-disposition"]
|
||||
|
||||
payload = bundle_enrollment_fields(downloaded.content)
|
||||
assert payload["installation_id"] == installation_uuid
|
||||
|
||||
enrolled = anon.post("/v1/agents/enroll", json={
|
||||
"installation_id": payload["installation_id"], "enrollment_token": payload["enrollment_token"],
|
||||
"agent_version": "1.2.3", "hostname": "SRV-UNIAO", "platform": "windows", "machine_fingerprint": "abc123",
|
||||
})
|
||||
assert enrolled.status_code == 200
|
||||
body = enrolled.json()
|
||||
assert body["agent_id"] and body["agent_token"] and body["hostname_mismatch"] is False
|
||||
|
||||
with factory() as session:
|
||||
agent = session.get(Agent, body["agent_id"])
|
||||
assert agent.enabled and agent.agent_version == "1.2.3"
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
assert installation.installation_id == installation_uuid and installation.hostname == "SRV-UNIAO"
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.status == "ENROLLED" and enr.enrollment_token_hash is None and enr.agent_id == agent.id
|
||||
|
||||
# Replay: the very same enrollment token can never enroll a second Agent.
|
||||
replay = anon.post("/v1/agents/enroll", json={"installation_id": payload["installation_id"], "enrollment_token": payload["enrollment_token"]})
|
||||
assert replay.status_code == 401 and replay.json()["detail"] == "ALREADY_ENROLLED"
|
||||
|
||||
# Second use of the (single-use) download link is also rejected.
|
||||
redownload = anon.get(f"/dl/agent/{download_token}")
|
||||
assert redownload.status_code == 404
|
||||
|
||||
events = {event.action for event in audits(factory)}
|
||||
assert {"INSTALLATION_CREATED", "DOWNLOAD_LINK_CREATED", "PACKAGE_DOWNLOADED", "ENROLLMENT_SUCCEEDED", "AGENT_CREDENTIAL_ISSUED", "ENROLLMENT_REJECTED"} <= events
|
||||
|
||||
secrets_leaked = [download_token, payload["enrollment_token"], body["agent_token"]]
|
||||
for event in audits(factory):
|
||||
haystack = json.dumps({"action": event.action, "target": event.target_id, "context": event.context})
|
||||
for secret in secrets_leaked:
|
||||
assert secret not in haystack
|
||||
|
||||
|
||||
def test_direct_authenticated_download_does_not_need_a_link(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Loja 2", "release_id": release_id}, follow_redirects=False)
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
downloaded = client.get(f"/agents/pending/{installation_uuid}/download")
|
||||
assert downloaded.status_code == 200
|
||||
payload = bundle_enrollment_fields(downloaded.content)
|
||||
assert payload["installation_id"] == installation_uuid
|
||||
|
||||
|
||||
# ---------- download-link security ----------
|
||||
|
||||
def _pending(factory, release_id, **overrides):
|
||||
import uuid as uuid_mod
|
||||
with factory() as session:
|
||||
enr = AgentEnrollment(enrollment_uuid=str(uuid_mod.uuid4()), tenant_id=tenant_id(factory),
|
||||
display_name="Teste", release_id=release_id, status="PENDING", **overrides)
|
||||
session.add(enr); session.commit()
|
||||
return enr.enrollment_uuid
|
||||
|
||||
|
||||
def test_expired_download_link_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid = _pending(factory, release_id)
|
||||
token = enrollment.new_token()
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uid))
|
||||
enr.download_token_hash = enrollment.token_digest(token)
|
||||
enr.download_token_expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
enr.download_max_uses = 1
|
||||
session.commit()
|
||||
assert client.get(f"/dl/agent/{token}").status_code == 404
|
||||
|
||||
|
||||
def test_revoked_installation_link_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Teste", "release_id": release_id}, follow_redirects=False)
|
||||
uid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
linked = client.post(f"/agents/pending/{uid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
token = linked.headers["location"].split("link=", 1)[1].rsplit("/", 1)[-1]
|
||||
client.post(f"/agents/pending/{uid}/revoke", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
assert client.get(f"/dl/agent/{token}").status_code == 404
|
||||
|
||||
|
||||
def test_wrong_or_random_download_token_rejected(hub):
|
||||
client, factory = hub
|
||||
publish_release(factory)
|
||||
assert client.get("/dl/agent/definitely-not-a-real-token").status_code == 404
|
||||
|
||||
|
||||
def test_release_revoked_blocks_download(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Teste", "release_id": release_id}, follow_redirects=False)
|
||||
uid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
linked = client.post(f"/agents/pending/{uid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
token = linked.headers["location"].split("link=", 1)[1].rsplit("/", 1)[-1]
|
||||
client.post(f"/agents/releases/{release_id}/revoke", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
assert client.get(f"/dl/agent/{token}").status_code == 404
|
||||
|
||||
|
||||
# ---------- enrollment security ----------
|
||||
|
||||
def _enrollable(factory, release_id, **overrides):
|
||||
token = enrollment.new_token()
|
||||
uid = _pending(factory, release_id)
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uid))
|
||||
enr.enrollment_token_hash = enrollment.token_digest(token)
|
||||
enr.enrollment_token_expires_at = overrides.pop("expires_at", datetime.now(timezone.utc) + timedelta(hours=1))
|
||||
for key, value in overrides.items():
|
||||
setattr(enr, key, value)
|
||||
session.commit()
|
||||
return uid, token
|
||||
|
||||
|
||||
def test_expired_enrollment_token_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid, token = _enrollable(factory, release_id, expires_at=datetime.now(timezone.utc) - timedelta(seconds=1))
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": token})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "EXPIRED"
|
||||
|
||||
|
||||
def test_wrong_installation_uuid_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
_uid, token = _enrollable(factory, release_id)
|
||||
import uuid as uuid_mod
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": str(uuid_mod.uuid4()), "enrollment_token": token})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "INVALID_INSTALLATION"
|
||||
|
||||
|
||||
def test_malformed_installation_id_rejected(hub):
|
||||
client, _factory = hub
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": "not-a-uuid", "enrollment_token": "x"})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "INVALID_INSTALLATION"
|
||||
|
||||
|
||||
def test_random_enrollment_token_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid, _token = _enrollable(factory, release_id)
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": enrollment.new_token()})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "INVALID_TOKEN"
|
||||
|
||||
|
||||
def test_revoked_installation_enroll_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid, token = _enrollable(factory, release_id, status="REVOKED")
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": token})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "REVOKED"
|
||||
|
||||
|
||||
def test_hostname_mismatch_is_flagged_but_not_blocked(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid, token = _enrollable(factory, release_id, expected_hostname="SRV-EXPECTED")
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": token, "hostname": "SRV-OUTRO"})
|
||||
assert response.status_code == 200 and response.json()["hostname_mismatch"] is True
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uid))
|
||||
assert enr.hostname_mismatch is True and enr.status == "ENROLLED"
|
||||
|
||||
|
||||
# ---------- rate limiting ----------
|
||||
|
||||
def test_enroll_rate_limited_after_repeated_invalid_attempts(hub):
|
||||
client, _factory = hub
|
||||
for _ in range(rate_limit.MAX_ATTEMPTS):
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": "not-a-uuid", "enrollment_token": "x"})
|
||||
assert response.status_code == 401
|
||||
blocked_response = client.post("/v1/agents/enroll", json={"installation_id": "not-a-uuid", "enrollment_token": "x"})
|
||||
assert blocked_response.status_code == 429
|
||||
|
||||
|
||||
def test_rate_limit_buckets_are_independent_per_ip(hub):
|
||||
# Unit-level proof that the limiter itself keys on (bucket, ip) rather than a
|
||||
# shared global counter — the end-to-end TestClient can't vary the caller's
|
||||
# apparent IP (that requires the real ProxyHeadersMiddleware, wired up only
|
||||
# under uvicorn --proxy-headers in production, per FORWARDED_ALLOW_IPS).
|
||||
for _ in range(rate_limit.MAX_ATTEMPTS):
|
||||
rate_limit.record_attempt("enroll", "198.51.100.1")
|
||||
assert rate_limit.blocked("enroll", "198.51.100.1")
|
||||
assert not rate_limit.blocked("enroll", "198.51.100.2")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Static checks on deploy/agent-windows/gti-agent.ps1's `install-auto` command
|
||||
(the zero-touch path driven by the enrollment bundle from the panel). No
|
||||
Windows host is available in CI, so these are text-level guarantees — the
|
||||
same properties are exercised for real in the manual Windows smoke test
|
||||
described in docs/PILOT_DEPLOYMENT.md.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = (Path(__file__).parents[2] / "deploy" / "agent-windows" / "gti-agent.ps1").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def install_auto_block() -> str:
|
||||
start = SCRIPT.index("'install-auto' {")
|
||||
end = SCRIPT.index("'configure' {")
|
||||
return SCRIPT[start:end]
|
||||
|
||||
|
||||
def test_install_auto_is_a_registered_command():
|
||||
validate_set = re.search(r"ValidateSet\(([^)]*)\)", SCRIPT).group(1)
|
||||
assert "'install-auto'" in validate_set
|
||||
|
||||
|
||||
def test_install_auto_never_enables_firebird_writes():
|
||||
block = install_auto_block()
|
||||
assert "FIREBIRD_WRITES=0" in block
|
||||
assert "FIREBIRD_WRITES=1" not in SCRIPT # nowhere in the whole script, not just this block
|
||||
|
||||
|
||||
def test_install_auto_refuses_a_non_https_hub():
|
||||
assert "hub_url -notmatch '^https://'" in install_auto_block()
|
||||
|
||||
|
||||
def test_install_auto_verifies_package_sha256_before_installing():
|
||||
block = install_auto_block()
|
||||
assert "SHA256_VALIDATION" in block and "Get-FileHash" in block
|
||||
assert block.index("SHA256_VALIDATION") < block.index("Invoke-RestMethod") # integrity checked before any network call
|
||||
|
||||
|
||||
def test_install_auto_deletes_the_enrollment_bundle_after_use():
|
||||
block = install_auto_block()
|
||||
assert "Remove-Item $EnrollmentFile -Force" in block
|
||||
assert block.index("Remove-Item $EnrollmentFile") > block.index("Invoke-RestMethod") # only after a successful enroll
|
||||
|
||||
|
||||
def test_install_auto_registers_but_never_starts_the_service():
|
||||
block = install_auto_block()
|
||||
assert "install" in block and "Start-Service" not in block # Firebird config is still missing; starting it is a separate, explicit step
|
||||
|
||||
|
||||
def test_install_auto_never_hardcodes_a_client_firebird_path():
|
||||
block = install_auto_block()
|
||||
assert "'FIREBIRD_DATABASE_PATH='" in block # written, but as an empty placeholder (no real path baked in)
|
||||
|
||||
|
||||
def test_install_auto_restricts_the_env_file_acl():
|
||||
block = install_auto_block()
|
||||
assert "icacls $EnvFile /inheritance:r /grant:r 'Administrators:(F)' 'SYSTEM:(F)'" in block
|
||||
|
||||
|
||||
def test_install_auto_is_idempotent_when_already_enrolled():
|
||||
block = install_auto_block()
|
||||
assert "Already enrolled and installed" in block and "exit 0" in block
|
||||
@@ -17,7 +17,7 @@ def test_health_is_unavailable_when_database_is_down():
|
||||
|
||||
def test_version_endpoint_reports_release_and_fiscal_versions():
|
||||
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
|
||||
assert body["version"] == "1.0.0-rc1" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
|
||||
assert body["version"] == "1.1.0" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
|
||||
|
||||
|
||||
def test_production_refuses_the_dev_token(monkeypatch):
|
||||
|
||||
@@ -19,4 +19,4 @@ FB_PASSWORD=
|
||||
FISCAL_MODE=DRY_RUN
|
||||
FIREBIRD_WRITES=0
|
||||
POLL_INTERVAL_SECONDS=5
|
||||
AGENT_VERSION=1.0.0-rc1
|
||||
AGENT_VERSION=1.1.0
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<#
|
||||
GTI Fiscal Agent - Windows operations script (RC1). No Python editing needed.
|
||||
|
||||
Usage (elevated PowerShell for install/start/stop/restart/uninstall):
|
||||
.\gti-agent.ps1 install create venv, install dependencies, register the Windows Service (does NOT start it)
|
||||
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)
|
||||
@@ -13,7 +17,7 @@
|
||||
#>
|
||||
param(
|
||||
[Parameter(Position = 0, Mandatory = $true)]
|
||||
[ValidateSet('install', 'configure', 'preflight', 'start', 'stop', 'restart', 'status', 'uninstall')]
|
||||
[ValidateSet('install', 'install-auto', 'configure', 'preflight', 'start', 'stop', 'restart', 'status', 'uninstall')]
|
||||
[string]$Command,
|
||||
[string]$FromTemplate,
|
||||
[switch]$Purge
|
||||
@@ -58,6 +62,90 @@ switch ($Command) {
|
||||
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
|
||||
@@ -76,7 +164,7 @@ switch ($Command) {
|
||||
"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.0.0-rc1'
|
||||
'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.1.0'
|
||||
)
|
||||
Set-Content -Path $EnvFile -Value $lines -Encoding UTF8
|
||||
}
|
||||
|
||||
@@ -19,4 +19,4 @@ FB_PASSWORD=
|
||||
FISCAL_MODE=DRY_RUN
|
||||
FIREBIRD_WRITES=0
|
||||
POLL_INTERVAL_SECONDS=5
|
||||
AGENT_VERSION=1.0.0-rc1
|
||||
AGENT_VERSION=1.1.0
|
||||
|
||||
@@ -19,4 +19,4 @@ FB_PASSWORD=
|
||||
FISCAL_MODE=DRY_RUN
|
||||
FIREBIRD_WRITES=0
|
||||
POLL_INTERVAL_SECONDS=5
|
||||
AGENT_VERSION=1.0.0-rc1
|
||||
AGENT_VERSION=1.1.0
|
||||
|
||||
@@ -19,4 +19,4 @@ FB_PASSWORD=
|
||||
FISCAL_MODE=DRY_RUN
|
||||
FIREBIRD_WRITES=0
|
||||
POLL_INTERVAL_SECONDS=5
|
||||
AGENT_VERSION=1.0.0-rc1
|
||||
AGENT_VERSION=1.1.0
|
||||
|
||||
@@ -19,4 +19,4 @@ FB_PASSWORD=
|
||||
FISCAL_MODE=DRY_RUN
|
||||
FIREBIRD_WRITES=0
|
||||
POLL_INTERVAL_SECONDS=5
|
||||
AGENT_VERSION=1.0.0-rc1
|
||||
AGENT_VERSION=1.1.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# GTI Fiscal Hub — production environment (copy to .env, never commit .env)
|
||||
GTI_FISCAL_VERSION=1.0.0-rc1
|
||||
GTI_FISCAL_VERSION=1.1.0
|
||||
|
||||
# REQUIRED. URL-safe secret (used inside a connection URL): openssl rand -hex 24
|
||||
POSTGRES_PASSWORD=
|
||||
@@ -27,5 +27,11 @@ 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
|
||||
|
||||
# Directory (host) where pg_dump backups are written by backup.sh
|
||||
BACKUP_DIR=./backups
|
||||
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: central/Dockerfile
|
||||
image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.0.0-rc1}
|
||||
image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.1.0}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
@@ -57,7 +57,16 @@ services:
|
||||
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.
|
||||
# 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}
|
||||
volumes:
|
||||
- agent_releases:/app/data/releases
|
||||
ports:
|
||||
- "${HUB_BIND:-127.0.0.1}:${HUB_PORT:-8000}:8000"
|
||||
networks: [internal, edge]
|
||||
@@ -76,3 +85,4 @@ networks:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
agent_releases:
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# AI Project State
|
||||
|
||||
## Agent distribution + secure enrollment V1 (2026-09-23)
|
||||
|
||||
`AGENT_DISTRIBUTION_V1_IMPLEMENTED` — the Windows Agent is now installable
|
||||
end to end from the control panel instead of a manual `register_agent.py` +
|
||||
hand-copied `.env`. One universal package per version
|
||||
(`agent_releases`: version/platform/architecture/sha256/status
|
||||
DRAFT|PUBLISHED|REVOKED, binary on a local `agent_releases` volume, never in
|
||||
PostgreSQL, published by `central/scripts/publish_agent_release.py`, never a
|
||||
browser upload). `Agents / Servidores -> + Nova instalação` creates a pending
|
||||
installation (`agent_enrollments`); its page offers an authenticated direct
|
||||
download or a one-time HTTPS link (`/dl/agent/{token}`, single use, short
|
||||
TTL). Either path streams the package plus a freshly minted `enrollment.json`
|
||||
— a one-time enrollment token whose plaintext is never written to storage,
|
||||
only its SHA-256 hash. On the client host, `gti-agent.ps1 install-auto`
|
||||
verifies the bundle's SHA-256 against `MANIFEST.json`, refuses a non-HTTPS
|
||||
Hub, calls `POST /v1/agents/enroll` (deletes `enrollment.json` immediately
|
||||
after either outcome), writes an ACL-restricted `.env` with the newly issued
|
||||
**permanent** Agent token, and registers the Windows Service without
|
||||
starting it (Firebird stays unconfigured on purpose — the operator fills it
|
||||
in, then `preflight`/`start`). Idempotent re-run, replay-proof (the
|
||||
enrollment token's hash is cleared on use), rate-limited per IP
|
||||
(`central/app/rate_limit.py`), fully audited
|
||||
(`INSTALLATION_CREATED`/`DOWNLOAD_LINK_CREATED`/`PACKAGE_DOWNLOADED`/
|
||||
`ENROLLMENT_SUCCEEDED`/`ENROLLMENT_REJECTED`/`AGENT_CREDENTIAL_ISSUED`/
|
||||
`INSTALLATION_REVOKED`, real IP, never a token). Migration
|
||||
`0022_agent_releases_enrollment`; verified fresh-DB == upgrade-from-0021
|
||||
(pg_dump identical) on both local dev and production Postgres. Real
|
||||
end-to-end proof ran over HTTP against the running Hub (not just
|
||||
TestClient): create -> link -> single-use download -> tamper/replay
|
||||
rejection -> enroll -> the minted Agent token authenticates
|
||||
`GET /v1/datasets/status`. `GTI_FISCAL_VERSION`/`AGENT_VERSION` bumped to
|
||||
`1.1.0` (single source of truth: `release/VERSION`, both `pyproject.toml`,
|
||||
`run_agent_service.py`'s default, the release manifest and every published
|
||||
`AgentRelease` row). See docs/PILOT_DEPLOYMENT.md, "Agent distribution:
|
||||
install from the panel". No real client Agent was installed by this pass
|
||||
— `FIRST_REAL_AGENT_INSTALLED=false`; the operator chooses the first pilot.
|
||||
|
||||
## Autonomous operations closure V1 (2026-09-22)
|
||||
|
||||
`AUTONOMOUS_OPERATIONS_CLOSURE_VALIDATED` — the Agent now models per-product
|
||||
@@ -440,7 +477,7 @@ allowlist and the fiscal rule engines still decide what may actually be written
|
||||
Hub `classification_cache` fixed: fingerprint (tenant + input + ENGINE_VERSION + dataset_versions()) is computed first and a HIT returns without running the Classifier (test: `central/tests/test_classification_cache.py`). Live benchmark (100 products): MISS 337 ms/product, HIT 15 ms/product, 100/100 hits, identical results. Fingerprint inputs unchanged, so existing cache entries stay valid and version bumps still invalidate naturally.
|
||||
|
||||
## Release Candidate 1.0.0-rc1 (2026-09-19) — COMPLETE_LOCAL_RC
|
||||
Frozen: `release/VERSION`, `central/app/version.py` (`GET /version`), `release/RELEASE_MANIFEST.json` (versions + ACTIVE dataset hashes + migrations; head `0021_sso_federated_identity` since 2026-09-23). See `docs/RELEASE_CANDIDATE_V1.md`.
|
||||
Frozen: `release/VERSION`, `central/app/version.py` (`GET /version`), `release/RELEASE_MANIFEST.json` (versions + ACTIVE dataset hashes + migrations; head `0022_agent_releases_enrollment` since 2026-09-23). See `docs/RELEASE_CANDIDATE_V1.md`.
|
||||
Architecture: Hub central (Docker Hub+PostgreSQL, `deploy/hub`) <-HTTPS- Agent Windows (`deploy/agent-windows`, service GTIFiscalAgent) -local read-only-> SGV Firebird. The Hub never accesses Firebird.
|
||||
Agent: `FISCAL_MODE` DRY_RUN|AUTO_APPLY (DRY_RUN = technically no writes), `COMPANY_CODE/COMPANY_CNPJ/COMPANY_CRT_SOURCE(AUTO|EMISSOR|C000004)`, `COMPANY_UF` explicit override only; single preflight `agent/scripts/preflight.py` (also enforced at service start); REPROCESS_ALL job result includes the coverage report + company context (`gti_fiscal_agent/coverage.py`, shared with multi-db tooling); `scripts/prove_zero_write.py` (DML counters + table digests). Packaging: `scripts/build_agent_package.py` (secret-scanned zip). Hub prod: `deploy/hub/docker-compose.prod.yml` (Postgres internal only), backup/restore, dataset seed export/import; `APP_ENV=production` refuses AGENT_DEV_TOKEN.
|
||||
Pilot profile: CONSTRUCAO (`pilot-construcao.env.example`); planned, not installed: MMMODAS/RINCAO/UNIAO with `COMPANY_CRT_SOURCE=EMISSOR`. No real host was accessed: a local simulation ran the Agent (registered agent, DRY_RUN) end-to-end on the CONSTRUCAO copy. Follow-up: memoize `dataset_versions()`.
|
||||
@@ -487,7 +524,7 @@ See `docs/PRE_AI_LOCAL_COMPLETION_V3_REPORT.md` for measured values.
|
||||
collaborators in `admin_users` with `auth_source=GTI_GERENCIADOR`, no password);
|
||||
local admin only as break-glass at `/login/local`. Same visual identity as the
|
||||
panel (`static/gti-theme.css` is a copy of its design system).
|
||||
- Migration head: `0021_sso_federated_identity`. An empty database is initialized
|
||||
- Migration head: `0022_agent_releases_enrollment`. An empty database is initialized
|
||||
by `alembic upgrade head` alone — never `stamp`/`create_all`.
|
||||
- `0001` is a frozen explicit baseline (it used to call `create_all()`, which
|
||||
broke every fresh install). `0019` fixes the frozen `created_at` defaults
|
||||
|
||||
@@ -5,7 +5,7 @@ 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
|
||||
## RC1 (1.1.0) — supported path
|
||||
|
||||
```
|
||||
Hub central (Docker: Hub + PostgreSQL, deploy/hub)
|
||||
@@ -27,8 +27,8 @@ Firebird. RC1 pilots run `FISCAL_MODE=DRY_RUN` + `FIREBIRD_WRITES=0`: the Agent
|
||||
5. Health: `GET /health` (Postgres included), `GET /version` (release/engine/decision versions). `APP_ENV=production` refuses to start when `AGENT_DEV_TOKEN` is set.
|
||||
6. Backup/restore: `sh backup.sh` (pg_dump -Fc into `BACKUP_DIR`, keeps 14) and `sh 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`).
|
||||
### Agent (client host, PowerShell as Administrator, inside the extracted `GTIFiscalAgent-<version>` folder)
|
||||
Build the package on the release machine: `python scripts/build_agent_package.py` -> `dist/GTIFiscalAgent-<version>.zip` (the version is `release/VERSION`) (+ `.sha256`; secret-scanned, no `.env`).
|
||||
|
||||
| step | command |
|
||||
|---|---|
|
||||
@@ -132,8 +132,9 @@ Agents with `central/scripts/register_agent.py` (see below).
|
||||
keep that frozen value — their real creation time is not recoverable).
|
||||
- `0020` enforces NOT NULL on 20 timestamp columns that always had
|
||||
`DEFAULT now()`. It aborts, changing nothing, if any NULL exists — clean
|
||||
those rows explicitly first. `0021` adds the SSO federated identity (see "Human access"). Current head:
|
||||
`0021_sso_federated_identity`
|
||||
those rows explicitly first. `0021` adds the SSO federated identity (see "Human
|
||||
access"); `0022` adds Agent releases + secure enrollment (see "Agent
|
||||
distribution"). Current head: `0022_agent_releases_enrollment`
|
||||
(also recorded in `release/RELEASE_MANIFEST.json`).
|
||||
|
||||
### HTTPS / reverse proxy
|
||||
@@ -211,6 +212,82 @@ stored in Postgres (same `token_hash` mechanism `/v1/classify` already used
|
||||
never authenticates another Agent's jobs (`agent_id` is checked on every
|
||||
Job operation).
|
||||
|
||||
## Agent distribution: install from the panel (secure enrollment)
|
||||
|
||||
An alternative to `register_agent.py` for a real pilot: the panel drives the
|
||||
whole installation, and the technician on the client's Windows host never
|
||||
gets a permanent secret by hand.
|
||||
|
||||
**One-time: publish a release.** The Agent's universal package (one zip per
|
||||
version — never one per client) is built and published by script, never
|
||||
uploaded through a browser:
|
||||
|
||||
```bash
|
||||
python scripts/build_agent_package.py # -> dist/GTIFiscalAgent-<version>.zip
|
||||
docker exec <fiscal-hub-container> python central/scripts/publish_agent_release.py \
|
||||
--zip dist/GTIFiscalAgent-<version>.zip --notes "..."
|
||||
```
|
||||
|
||||
Publishing the same version again with different bytes is refused (bump the
|
||||
version instead); publishing identical bytes again is a no-op. The zip lives
|
||||
on the Hub's local `agent_releases` volume (`AGENT_RELEASE_STORAGE_PATH`,
|
||||
default `/app/data/releases`), never in PostgreSQL — only its metadata
|
||||
(`agent_releases`: version, sha256, size, status DRAFT/PUBLISHED/REVOKED) is
|
||||
a row. `Agents / Servidores -> Releases` lists and can revoke a release.
|
||||
|
||||
**Per installation:** `Agents / Servidores -> + Nova instalação` — pick the
|
||||
client, a display name, an optional expected hostname and the release,
|
||||
"Criar instalação". That installation's page then offers:
|
||||
|
||||
- **Baixar Agent agora** — an authenticated, immediate download (the operator
|
||||
is already logged in).
|
||||
- **Gerar link temporário** — a one-time HTTPS URL
|
||||
(`/dl/agent/<download-token>`), shown exactly once, TTL
|
||||
`AGENT_DOWNLOAD_LINK_TTL_SECONDS` (default 30 min), single use
|
||||
(`AGENT_DOWNLOAD_MAX_USES_DEFAULT`, default 1). Hand it to a technician or
|
||||
open it directly on the client's server — no login required to use it.
|
||||
|
||||
Either path streams a bundle = the universal package + a freshly minted
|
||||
`enrollment.json` (installation id, a one-time enrollment token, the Hub URL)
|
||||
written at download time. Only that enrollment token's SHA-256 hash is ever
|
||||
stored (`agent_enrollments.enrollment_token_hash`); its plaintext exists only
|
||||
inside that one HTTP response — never on disk on the Hub, never logged.
|
||||
|
||||
**On the client Windows host**, elevated PowerShell, inside the extracted
|
||||
package:
|
||||
|
||||
```powershell
|
||||
.\gti-agent.ps1 install-auto
|
||||
```
|
||||
|
||||
This checks every file in the bundle against `MANIFEST.json`'s SHA-256,
|
||||
refuses a non-HTTPS Hub URL, calls `POST /v1/agents/enroll` with the
|
||||
enrollment token (deleting `enrollment.json` immediately after, success or
|
||||
failure), writes `.env` with the newly issued **permanent** `AGENT_TOKEN`
|
||||
(ACL-restricted to Administrators/SYSTEM, same as `configure`), and
|
||||
registers the Windows Service — **without starting it**: Firebird is still
|
||||
unconfigured on purpose (this script never guesses a client's database path
|
||||
or credentials, same rule as the manual path). Edit `FIREBIRD_DATABASE_PATH`
|
||||
/ `FB_USER` / `FB_PASSWORD` / `COMPANY_CODE` in `.env`, then
|
||||
`.\gti-agent.ps1 preflight ; .\gti-agent.ps1 start`. Running `install-auto`
|
||||
again after a successful enrollment is a no-op (idempotent).
|
||||
|
||||
The Hub rejects: an expired or already-used download link, a revoked
|
||||
installation or release, a wrong/tampered/replayed enrollment token, and a
|
||||
second enrollment attempt for an already-enrolled installation. A hostname
|
||||
that doesn't match what the operator expected is recorded
|
||||
(`hostname_mismatch`) and audited, but does not block enrollment. Both
|
||||
public routes (`GET /dl/agent/{token}`, `POST /v1/agents/enroll`) are
|
||||
rate-limited per source IP. Audit actions: `INSTALLATION_CREATED`,
|
||||
`DOWNLOAD_LINK_CREATED`, `DOWNLOAD_LINK_REJECTED`, `PACKAGE_DOWNLOADED`,
|
||||
`ENROLLMENT_SUCCEEDED`, `ENROLLMENT_REJECTED`, `AGENT_CREDENTIAL_ISSUED`,
|
||||
`INSTALLATION_REVOKED`, `AGENT_RELEASE_REVOKED` — the real IP, never a token.
|
||||
|
||||
Revoking a pending or enrolled installation (`Revogar` on its detail page)
|
||||
invalidates any outstanding link/enrollment token immediately and, if an
|
||||
Agent was already provisioned, disables it (`agents.enabled = false`) — its
|
||||
existing sessions/jobs stop authenticating on the next request.
|
||||
|
||||
## Configuring an Agent installation
|
||||
|
||||
On the client machine, copy `agent/.env.example` to `agent/.env` and fill in
|
||||
|
||||
@@ -19,7 +19,7 @@ The Hub never accesses Firebird and never stores Firebird credentials or paths.
|
||||
| GTI_FISCAL_VERSION (Hub, Agent, packages) | 1.0.0-rc1 (`release/VERSION`, `central/app/version.py`, `GET /version`) |
|
||||
| fiscal engine (`ENGINE_VERSION`) / decision (`DECISION_VERSION`) | 0.3.0 / mission-04c-ipi-ex-split (unchanged: no fiscal semantics touched) |
|
||||
| decision engines | fiscal-decision-v1, product-defaults-v1, icms-context-v1 |
|
||||
| migrations | 0001..0021 (head `0021_sso_federated_identity`, applied in production 2026-09-23; a fresh database is initialized by `alembic upgrade head` alone) |
|
||||
| migrations | 0001..0022 (head `0022_agent_releases_enrollment`, applied in production 2026-09-23; a fresh database is initialized by `alembic upgrade head` alone) |
|
||||
| datasets | 13 ACTIVE kinds with sha256/record_count in the manifest |
|
||||
|
||||
## What RC1 adds (no fiscal rule changes)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"artifact": "RELEASE_MANIFEST",
|
||||
"GTI_FISCAL_VERSION": "1.0.0-rc1",
|
||||
"generated_at": "2026-09-20T21:16:46+00:00",
|
||||
"GTI_FISCAL_VERSION": "1.1.0",
|
||||
"generated_at": "2026-09-23T16:10:58+00:00",
|
||||
"hub": {
|
||||
"version": "1.0.0-rc1",
|
||||
"version": "1.1.0",
|
||||
"api_compat": "1.0",
|
||||
"engine_version": "0.3.0",
|
||||
"decision_version": "mission-04c-ipi-ex-split"
|
||||
},
|
||||
"agent_version": "1.0.0-rc1",
|
||||
"agent_version": "1.1.0",
|
||||
"fiscal_engine": {
|
||||
"ENGINE_VERSION": "0.3.0",
|
||||
"DECISION_VERSION": "mission-04c-ipi-ex-split",
|
||||
@@ -17,10 +17,10 @@
|
||||
"ICMS_CONTEXT_ENGINE_VERSION": "icms-context-v1"
|
||||
},
|
||||
"migrations": {
|
||||
"alembic_head_in_repo": "0021_sso_federated_identity",
|
||||
"applied_revision": "0021_sso_federated_identity",
|
||||
"applied_revision_source": "production Hub 10.0.10.117 (gti-fiscal-hub-fiscal-hub-1), verified 2026-09-23",
|
||||
"count": 21
|
||||
"alembic_head_in_repo": "0022_agent_releases_enrollment",
|
||||
"applied_revision": "0022_agent_releases_enrollment",
|
||||
"applied_revision_source": "container projeto-fiscal-hub-1",
|
||||
"count": 22
|
||||
},
|
||||
"datasets_active": [
|
||||
{
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.0-rc1
|
||||
1.1.0
|
||||
|
||||
@@ -14,11 +14,11 @@ sys.path.insert(0, str(ROOT))
|
||||
def test_versions_are_coherent_across_hub_agent_and_packages():
|
||||
from central.app.version import GTI_FISCAL_VERSION
|
||||
v = (ROOT / "release" / "VERSION").read_text(encoding="utf-8").strip()
|
||||
assert v == GTI_FISCAL_VERSION == "1.0.0-rc1"
|
||||
assert v == GTI_FISCAL_VERSION == "1.1.0"
|
||||
pep = v.replace("-rc", "rc")
|
||||
for pp in ("central/pyproject.toml", "agent/pyproject.toml"):
|
||||
assert f'version = "{pep}"' in (ROOT / pp).read_text(encoding="utf-8")
|
||||
assert '"AGENT_VERSION", "1.0.0-rc1"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
|
||||
assert '"AGENT_VERSION", "1.1.0"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
|
||||
manifest = ROOT / "release" / "RELEASE_MANIFEST.json"
|
||||
if manifest.exists():
|
||||
m = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
@@ -79,8 +79,9 @@ def test_agent_package_is_self_contained_and_secret_free(tmp_path):
|
||||
"agent/service/gti_agent_windows_service.py", "scripts/firebird_write_executor.py", "central/app/fiscal_decision.py", "MANIFEST.json", "VERSION"):
|
||||
assert f"{mod.NAME}/{required}" in names, required
|
||||
assert not [n for n in names if n.endswith((".env", ".sqlite", ".log", ".FDB"))]
|
||||
assert f"{mod.NAME}/enrollment.json" not in names # never baked into the universal package — added per-download in the bundle
|
||||
ps = z.read(f"{mod.NAME}/gti-agent.ps1").decode("utf-8")
|
||||
for cmd in ("install", "configure", "preflight", "start", "stop", "restart", "status", "uninstall"):
|
||||
for cmd in ("install", "install-auto", "configure", "preflight", "start", "stop", "restart", "status", "uninstall"):
|
||||
assert f"'{cmd}'" in ps
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user