diff --git a/agent/pyproject.toml b/agent/pyproject.toml index b6a89fd..77c6f95 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -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"] diff --git a/agent/scripts/run_agent_service.py b/agent/scripts/run_agent_service.py index 235df82..264fd81 100644 --- a/agent/scripts/run_agent_service.py +++ b/agent/scripts/run_agent_service.py @@ -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": diff --git a/agent/src/gti_fiscal_agent/preflight.py b/agent/src/gti_fiscal_agent/preflight.py index 906b243..161994d 100644 --- a/agent/src/gti_fiscal_agent/preflight.py +++ b/agent/src/gti_fiscal_agent/preflight.py @@ -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") diff --git a/central/alembic/versions/0022_agent_releases_enrollment.py b/central/alembic/versions/0022_agent_releases_enrollment.py new file mode 100644 index 0000000..5bca75e --- /dev/null +++ b/central/alembic/versions/0022_agent_releases_enrollment.py @@ -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") diff --git a/central/app/agent_distribution.py b/central/app/agent_distribution.py new file mode 100644 index 0000000..9be99b3 --- /dev/null +++ b/central/app/agent_distribution.py @@ -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 diff --git a/central/app/config.py b/central/app/config.py index e8eff43..71e9526 100644 --- a/central/app/config.py +++ b/central/app/config.py @@ -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") diff --git a/central/app/enrollment.py b/central/app/enrollment.py new file mode 100644 index 0000000..57bfb6b --- /dev/null +++ b/central/app/enrollment.py @@ -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 + `/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() diff --git a/central/app/main.py b/central/app/main.py index 0c04a3e..5111089 100644 --- a/central/app/main.py +++ b/central/app/main.py @@ -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 diff --git a/central/app/models.py b/central/app/models.py index f782d6b..4a3e246 100644 --- a/central/app/models.py +++ b/central/app/models.py @@ -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) diff --git a/central/app/rate_limit.py b/central/app/rate_limit.py new file mode 100644 index 0000000..e82e622 --- /dev/null +++ b/central/app/rate_limit.py @@ -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 diff --git a/central/app/release_storage.py b/central/app/release_storage.py new file mode 100644 index 0000000..5bf9092 --- /dev/null +++ b/central/app/release_storage.py @@ -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 diff --git a/central/app/version.py b/central/app/version.py index 1bb46a1..c5d0faa 100644 --- a/central/app/version.py +++ b/central/app/version.py @@ -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 diff --git a/central/app/web.py b/central/app/web.py index f727934..0a67e01 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -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) diff --git a/central/pyproject.toml b/central/pyproject.toml index 58ab4d5..f847a67 100644 --- a/central/pyproject.toml +++ b/central/pyproject.toml @@ -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", diff --git a/central/scripts/publish_agent_release.py b/central/scripts/publish_agent_release.py new file mode 100644 index 0000000..8ddd0b8 --- /dev/null +++ b/central/scripts/publish_agent_release.py @@ -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-.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() diff --git a/central/templates/agent_new_installation.html b/central/templates/agent_new_installation.html new file mode 100644 index 0000000..d333391 --- /dev/null +++ b/central/templates/agent_new_installation.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Nova instalação | GTI Fiscal{% endblock %} +{% block content %} +

DISTRIBUIÇÃO DO AGENT

Nova instalação

Cria uma instalação pendente; o pacote e o vínculo de enrollment só existem depois de gerados aqui.

Voltar
+
+ {% if error %}{% endif %} + {% if not releases %} +

Nenhuma versão do Agent está publicada ainda. Publique uma release primeiro (python central/scripts/publish_agent_release.py) e ela aparecerá aqui.

+ {% else %} +
+ + + + + +

+
+ {% endif %} +
+{% endblock %} diff --git a/central/templates/agent_pending_detail.html b/central/templates/agent_pending_detail.html new file mode 100644 index 0000000..bfa37cf --- /dev/null +++ b/central/templates/agent_pending_detail.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}{{ enr.display_name }} | Instalação | GTI Fiscal{% endblock %} +{% block content %} +

DISTRIBUIÇÃO DO AGENT

{{ enr.display_name }}

{{ tenant.name if tenant else "Cliente não encontrado" }} · release {{ release.version if release else "?" }}

{{ enr.status }}
+ +{% if download_link %} +
+

Link temporário gerado

+

Mostrado uma única vez, uso único, expira em pouco tempo. Copie agora — ele não pode ser recuperado depois.

+

{{ download_link }}

+
+{% endif %} + +
+

Detalhes

+ + + + + + + + + + {% if installed_agent %} + + {% endif %} +
Cliente{{ tenant.name if tenant else "-" }}
Nome{{ enr.display_name }}
Hostname esperado{{ enr.expected_hostname or "não informado" }}
Versão do Agent{{ release.version if release else "-" }} ({{ release.platform if release else "" }}/{{ release.architecture if release else "" }})
Status{{ enr.status }}{% if enr.hostname_mismatch %} hostname divergente{% endif %}
Criada em{{ enr.created_at }}
Baixada em{{ enr.downloaded_at or "ainda não" }}
Hostname visto{{ enr.hostname_seen or "-" }}
Agent#{{ installed_agent.id }} · {{ "ativo" if installed_agent.enabled else "desativado" }} · último heartbeat: {{ installed_agent.last_seen_at or "nenhum ainda" }}
+
+ +
+

Ações

+ {% if enr.status not in ("ENROLLED", "REVOKED") %} +
+ + +
+ Baixar Agent agora + {% endif %} + {% if enr.status != "REVOKED" %} +
+ + +
+ {% endif %} + {% if enr.status == "ENROLLED" and installed_agent %} + Ver Agent + {% endif %} +
+{% endblock %} diff --git a/central/templates/agent_releases.html b/central/templates/agent_releases.html new file mode 100644 index 0000000..fe4378b --- /dev/null +++ b/central/templates/agent_releases.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Releases do Agent | GTI Fiscal{% endblock %} +{% block content %} +

DISTRIBUIÇÃO DO AGENT

Versões do Agent

O binário é universal por versão; publicação é feita por script (central/scripts/publish_agent_release.py), nunca por upload no navegador.

+ Nova instalação Voltar
+
+ + + + + {% for release in releases %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
Releases catalogadas
VersãoPlataformaStatusSHA-256TamanhoPublicada emInstalações
{{ release.version }}{{ release.platform }}/{{ release.architecture }}{{ release.status }}{{ release.sha256[:12] }}…{{ "%.1f"|format(release.size_bytes / 1048576) }} MB{{ release.published_at or "não publicada" }}{{ installation_counts.get(release.id, 0) }} + {% if release.status == "PUBLISHED" %} +
+ + +
+ {% endif %} +
Nenhuma release publicada ainda.
+
+{% endblock %} diff --git a/central/templates/agents.html b/central/templates/agents.html index 22a0260..9a7ee09 100644 --- a/central/templates/agents.html +++ b/central/templates/agents.html @@ -1,7 +1,8 @@ {% extends "base.html" %} {% block title %}Agents | GTI Fiscal{% endblock %} {% block content %} -

INVENTÁRIO OPERACIONAL

Agents / Servidores

Identidade do cliente primeiro; IDs técnicos ficam no detalhe.

+

INVENTÁRIO OPERACIONAL

Agents / Servidores

Identidade do cliente primeiro; IDs técnicos ficam no detalhe.

+ Nova instalação Releases
+
{% for item in result["items"] %}{% else %}{% endfor %}
Instalações monitoradas
ClienteNome de exibiçãoHostnameSaúde técnicaStatus fiscalCobertura fiscalProdutosVersãoÚltimo heartbeat
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "Não informado" }}{{ item.hostname or "Não informado" }}Saúde técnica: {{ item.technical_health_label }}Status fiscal: {{ item.fiscal_status_label }}{{ item.fiscally_complete }} completos · {{ item.safe_blocked }} bloqueados com segurança · {{ item.failed_products }} falhas · {{ item.unclassified_products }} não classificados{% if not item.coverage_invariant_pass %}Inconsistência: {{ item.coverage_overcount_products }} acima do total{% if item.coverage_invalid_counts %}; contagens negativas{% endif %}{% endif %}{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%{{ item.fiscally_complete }} / {{ item.total_products }} completos{{ item.total_products }}{{ item.failed_products }} falhas técnicas{{ item.agent_version_display }}{{ item.last_seen_display }}{{ item.last_seen_age }}
Nenhuma instalação encontrada.
{% endblock %} diff --git a/central/tests/test_agent_distribution.py b/central/tests/test_agent_distribution.py new file mode 100644 index 0000000..5b525e2 --- /dev/null +++ b/central/tests/test_agent_distribution.py @@ -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") diff --git a/central/tests/test_agent_windows_install_script.py b/central/tests/test_agent_windows_install_script.py new file mode 100644 index 0000000..c02a49c --- /dev/null +++ b/central/tests/test_agent_windows_install_script.py @@ -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 diff --git a/central/tests/test_health.py b/central/tests/test_health.py index b847e4b..31855aa 100644 --- a/central/tests/test_health.py +++ b/central/tests/test_health.py @@ -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): diff --git a/deploy/agent-windows/agent.env.template b/deploy/agent-windows/agent.env.template index ee66260..8094076 100644 --- a/deploy/agent-windows/agent.env.template +++ b/deploy/agent-windows/agent.env.template @@ -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 diff --git a/deploy/agent-windows/gti-agent.ps1 b/deploy/agent-windows/gti-agent.ps1 index 0ba9cdc..2086e97 100644 --- a/deploy/agent-windows/gti-agent.ps1 +++ b/deploy/agent-windows/gti-agent.ps1 @@ -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 \.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 } diff --git a/deploy/agent-windows/pilot-construcao.env.example b/deploy/agent-windows/pilot-construcao.env.example index 7863202..0181b34 100644 --- a/deploy/agent-windows/pilot-construcao.env.example +++ b/deploy/agent-windows/pilot-construcao.env.example @@ -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 diff --git a/deploy/agent-windows/profiles/mmmodas.env.example b/deploy/agent-windows/profiles/mmmodas.env.example index 12cbe4f..aa750b8 100644 --- a/deploy/agent-windows/profiles/mmmodas.env.example +++ b/deploy/agent-windows/profiles/mmmodas.env.example @@ -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 diff --git a/deploy/agent-windows/profiles/rincao.env.example b/deploy/agent-windows/profiles/rincao.env.example index 4ea91af..782974c 100644 --- a/deploy/agent-windows/profiles/rincao.env.example +++ b/deploy/agent-windows/profiles/rincao.env.example @@ -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 diff --git a/deploy/agent-windows/profiles/uniao.env.example b/deploy/agent-windows/profiles/uniao.env.example index 9a2cd9f..482ad99 100644 --- a/deploy/agent-windows/profiles/uniao.env.example +++ b/deploy/agent-windows/profiles/uniao.env.example @@ -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 diff --git a/deploy/hub/.env.example b/deploy/hub/.env.example index cabbd5d..d3dd82f 100644 --- a/deploy/hub/.env.example +++ b/deploy/hub/.env.example @@ -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 diff --git a/deploy/hub/docker-compose.prod.yml b/deploy/hub/docker-compose.prod.yml index 20f4e34..3740b20 100644 --- a/deploy/hub/docker-compose.prod.yml +++ b/deploy/hub/docker-compose.prod.yml @@ -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: diff --git a/docs/AI_PROJECT_STATE.md b/docs/AI_PROJECT_STATE.md index 47eee53..01db4d9 100644 --- a/docs/AI_PROJECT_STATE.md +++ b/docs/AI_PROJECT_STATE.md @@ -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 diff --git a/docs/PILOT_DEPLOYMENT.md b/docs/PILOT_DEPLOYMENT.md index c3aa73f..6170a51 100644 --- a/docs/PILOT_DEPLOYMENT.md +++ b/docs/PILOT_DEPLOYMENT.md @@ -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 ` (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-` folder) +Build the package on the release machine: `python scripts/build_agent_package.py` -> `dist/GTIFiscalAgent-.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-.zip +docker exec python central/scripts/publish_agent_release.py \ + --zip dist/GTIFiscalAgent-.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/`), 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 diff --git a/docs/RELEASE_CANDIDATE_V1.md b/docs/RELEASE_CANDIDATE_V1.md index 3bb0e7b..5ac755b 100644 --- a/docs/RELEASE_CANDIDATE_V1.md +++ b/docs/RELEASE_CANDIDATE_V1.md @@ -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) diff --git a/release/RELEASE_MANIFEST.json b/release/RELEASE_MANIFEST.json index 8ae71fa..e16eb4b 100644 --- a/release/RELEASE_MANIFEST.json +++ b/release/RELEASE_MANIFEST.json @@ -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": [ { diff --git a/release/VERSION b/release/VERSION index 867bf6b..9084fa2 100644 --- a/release/VERSION +++ b/release/VERSION @@ -1 +1 @@ -1.0.0-rc1 +1.1.0 diff --git a/tests/test_release_candidate.py b/tests/test_release_candidate.py index 853782b..fc63eab 100644 --- a/tests/test_release_candidate.py +++ b/tests/test_release_candidate.py @@ -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