feat: Agent distribution with secure enrollment from the control panel

One universal Windows Agent package per version (agent_releases: version/
platform/architecture/sha256/status DRAFT|PUBLISHED|REVOKED; the binary
lives on a local release-storage volume, never in PostgreSQL). An operator
creates a pending installation (agent_enrollments) from Agents / Servidores
-> + Nova instalação, then either downloads it directly or generates a
one-time HTTPS link (short TTL, single use). Either path streams the
package plus a freshly minted enrollment.json whose token is never written
to storage in plaintext, only its SHA-256 hash. On the client host,
gti-agent.ps1 install-auto verifies the bundle's SHA-256, refuses a non-
HTTPS Hub, enrolls via POST /v1/agents/enroll (deleting the enrollment
token immediately after, success or failure), writes an ACL-restricted
.env with the newly issued permanent Agent token, and registers the
Windows Service without starting it (Firebird stays unconfigured on
purpose). Idempotent, replay-proof, rate-limited per IP, fully audited,
never logs a secret.

Migration 0022_agent_releases_enrollment (verified fresh-DB ==
upgrade-from-0021 by pg_dump diff). GTI_FISCAL_VERSION/AGENT_VERSION
bumped to 1.1.0 as the single source of truth across code, packages, the
release manifest and every published AgentRelease row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-23 13:19:31 -03:00
co-authored by Claude Sonnet 5
parent b1c496fc1b
commit 8d6122c096
36 changed files with 1515 additions and 47 deletions
@@ -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")
+177
View File
@@ -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
+7
View File
@@ -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")
+84
View File
@@ -0,0 +1,84 @@
"""Secrets and package bundling for Agent distribution.
Two independent secrets guard the distribution flow, both random(256 bits),
single-use and short-TTL, both stored only as SHA-256 hashes:
- the *download* token: embedded in the shareable link an operator hands to
whoever installs the Agent (or clicked directly, authenticated, from the
panel);
- the *enrollment* token: embedded inside the downloaded bundle as
`enrollment.json`, consumed exactly once by `POST /v1/agents/enroll`.
Neither token is ever the Agent's own permanent Bearer token (issued only
after a successful enrollment) and neither is ever logged or audited in
plaintext.
"""
from __future__ import annotations
import hashlib
import hmac
import io
import json
import re
import secrets
import zipfile
from dataclasses import dataclass
class EnrollmentRejected(Exception):
def __init__(self, reason: str):
super().__init__(reason)
self.reason = reason
def new_token() -> str:
"""256 bits of randomness, URL-safe — usable directly in a link or a JSON field."""
return secrets.token_urlsafe(32)
def token_digest(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def token_matches(token: str | None, stored_hash: str | None) -> bool:
if not token or not stored_hash:
return False
return hmac.compare_digest(token_digest(token), stored_hash)
ENROLLMENT_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
def valid_uuid(value: str | None) -> bool:
return bool(value) and bool(ENROLLMENT_UUID_RE.match(value))
@dataclass(frozen=True)
class BundlePlan:
hub_url: str
enrollment_uuid: str
enrollment_token: str
enroll_path: str = "/v1/agents/enroll"
def build_bundle(release_zip_bytes: bytes, plan: BundlePlan) -> bytes:
"""Return a new zip = the release zip's entries plus one
`<package-root>/enrollment.json` holding only temporary, single-use data.
Never a permanent credential — that is issued only once enrollment succeeds."""
with zipfile.ZipFile(io.BytesIO(release_zip_bytes)) as source:
names = source.namelist()
if not names:
raise EnrollmentRejected("EMPTY_RELEASE_PACKAGE")
root = names[0].split("/", 1)[0]
out = io.BytesIO()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dest:
for name in names:
dest.writestr(name, source.read(name))
payload = {
"installation_id": plan.enrollment_uuid,
"enrollment_token": plan.enrollment_token,
"hub_url": plan.hub_url,
"enroll_path": plan.enroll_path,
}
dest.writestr(f"{root}/enrollment.json", json.dumps(payload, indent=1))
return out.getvalue()
+2
View File
@@ -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
+53
View File
@@ -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)
+36
View File
@@ -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
+35
View File
@@ -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
+8 -4
View File
@@ -1,4 +1,8 @@
"""Single source of truth for the release version (RC1). Fiscal semantics are versioned separately
(ENGINE_VERSION/DECISION_VERSION in classification.py, DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump."""
GTI_FISCAL_VERSION = "1.0.0-rc1"
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x talks to any Hub 1.0.x
"""Single source of truth for the release version. Also what the Agent Windows
package is versioned as (scripts/build_agent_package.py reads release/VERSION,
kept identical to this) and what an AgentRelease row's `version` column holds
once published — never a second, independent version number. Fiscal semantics
are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py,
DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump."""
GTI_FISCAL_VERSION = "1.1.0"
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x
+143 -3
View File
@@ -1,7 +1,8 @@
"""Server-rendered operator pages for the GTI Fiscal control panel."""
import secrets
from datetime import datetime, timezone
import uuid
from datetime import datetime, timedelta, timezone
from urllib.parse import parse_qs, urlencode
from fastapi import APIRouter, Request
@@ -9,14 +10,14 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from . import config, sso
from . import agent_distribution, config, enrollment, sso
from .config import settings
from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context
from .context_keys import ALLOWED_KEYS, ENUMS
from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, list_blockers, list_installations, list_jobs, list_panel_events
from .human_auth import create_session, current_session, require_csrf, verify_password
from .panel_presentation import format_ui_datetime
from .models import AdminUser, Agent, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant
from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant
from .jobs import create_job
from .audit import record_audit
@@ -207,6 +208,145 @@ def router(session_factory):
return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303)
finally: session.close()
@routes.get("/agents/releases", response_class=HTMLResponse)
def agent_releases(request: Request):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
releases = session.scalars(select(AgentRelease).order_by(AgentRelease.created_at.desc())).all()
counts = {}
for release_id, in session.execute(select(AgentEnrollment.release_id)).all():
counts[release_id] = counts.get(release_id, 0) + 1
return render(request, "agent_releases.html", {"releases": releases, "installation_counts": counts, "auth": auth_data})
finally: session.close()
@routes.post("/agents/releases/{release_id}/revoke")
async def revoke_release(request: Request, release_id: int):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
values = await form_values(request); web_session, admin = auth_data
require_csrf(web_session, values.get("csrf_token"))
release = session.get(AgentRelease, release_id)
if not release: return HTMLResponse("Release not found", status_code=404)
release.status = "REVOKED"
record_audit(session, admin_user_id=admin.id, action="AGENT_RELEASE_REVOKED", target_type="agent_release", target_id=release.version, context={"platform": release.platform, "architecture": release.architecture})
session.commit()
return RedirectResponse("/agents/releases", status_code=303)
finally: session.close()
@routes.get("/agents/new", response_class=HTMLResponse)
def new_installation_form(request: Request):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all()
releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all()
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": None})
finally: session.close()
@routes.post("/agents/new")
async def create_pending_installation(request: Request):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
values = await form_values(request); web_session, admin = auth_data
require_csrf(web_session, values.get("csrf_token"))
tenant_id = values.get("tenant_id")
display_name = (values.get("display_name") or "").strip()[:160]
expected_hostname = (values.get("expected_hostname") or "").strip()[:255] or None
release_id = values.get("release_id")
errors = []
tenant = session.get(Tenant, int(tenant_id)) if tenant_id and tenant_id.isdigit() else None
release = session.get(AgentRelease, int(release_id)) if release_id and release_id.isdigit() else None
if not tenant: errors.append("Selecione um cliente válido.")
if not display_name: errors.append("Informe um nome para a instalação.")
if not release or release.status != "PUBLISHED": errors.append("Selecione uma versão publicada do Agent.")
if errors:
tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all()
releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all()
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": " ".join(errors)}, 400)
enrollment_uuid = str(uuid.uuid4())
enr = AgentEnrollment(enrollment_uuid=enrollment_uuid, tenant_id=tenant.id, display_name=display_name, expected_hostname=expected_hostname, release_id=release.id, status="PENDING", created_by=admin.id)
session.add(enr)
record_audit(session, admin_user_id=admin.id, tenant_id=tenant.id, action="INSTALLATION_CREATED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"display_name": display_name, "release_version": release.version})
session.commit()
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
finally: session.close()
def _pending_or_404(session, enrollment_uuid):
return session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == enrollment_uuid))
@routes.get("/agents/pending/{enrollment_uuid}", response_class=HTMLResponse)
def pending_installation_detail(request: Request, enrollment_uuid: str, link: str | None = None):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
enr = _pending_or_404(session, enrollment_uuid)
if not enr: return HTMLResponse("Installation not found", status_code=404)
release = session.get(AgentRelease, enr.release_id)
tenant = session.get(Tenant, enr.tenant_id)
installed_agent = session.get(Agent, enr.agent_id) if enr.agent_id else None
return render(request, "agent_pending_detail.html", {"enr": enr, "release": release, "tenant": tenant, "installed_agent": installed_agent, "download_link": link, "auth": auth_data})
finally: session.close()
@routes.post("/agents/pending/{enrollment_uuid}/link")
async def create_download_link(request: Request, enrollment_uuid: str):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
values = await form_values(request); web_session, admin = auth_data
require_csrf(web_session, values.get("csrf_token"))
enr = _pending_or_404(session, enrollment_uuid)
if not enr: return HTMLResponse("Installation not found", status_code=404)
if enr.status in ("ENROLLED", "REVOKED"): return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
token = enrollment.new_token()
enr.download_token_hash = enrollment.token_digest(token)
enr.download_token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=config.settings.agent_download_link_ttl_seconds)
enr.download_max_uses = config.settings.agent_download_max_uses_default
enr.download_uses = 0
enr.status = "LINK_CREATED"
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="DOWNLOAD_LINK_CREATED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"ttl_seconds": config.settings.agent_download_link_ttl_seconds, "ip": client_ip(request)})
session.commit()
url = f"{config.settings.base_url}/dl/agent/{token}"
return RedirectResponse(f"/agents/pending/{enrollment_uuid}?link={url}", status_code=303)
finally: session.close()
@routes.get("/agents/pending/{enrollment_uuid}/download")
def direct_download(request: Request, enrollment_uuid: str):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
web_session, admin = auth_data
enr = _pending_or_404(session, enrollment_uuid)
if not enr: return HTMLResponse("Installation not found", status_code=404)
if enr.status == "REVOKED": return HTMLResponse("Installation revoked", status_code=409)
try:
return agent_distribution.build_and_serve(session, enr, request, actor_admin_id=admin.id)
except enrollment.EnrollmentRejected as rejected:
return HTMLResponse(f"Download indisponível: {rejected.reason}", status_code=409)
finally: session.close()
@routes.post("/agents/pending/{enrollment_uuid}/revoke")
async def revoke_pending_installation(request: Request, enrollment_uuid: str):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
values = await form_values(request); web_session, admin = auth_data
require_csrf(web_session, values.get("csrf_token"))
enr = _pending_or_404(session, enrollment_uuid)
if not enr: return HTMLResponse("Installation not found", status_code=404)
enr.status = "REVOKED"
enr.download_token_hash = None
enr.enrollment_token_hash = None
if enr.agent_id:
agent = session.get(Agent, enr.agent_id)
if agent: agent.enabled = False
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"had_agent": bool(enr.agent_id)})
session.commit()
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
finally: session.close()
@routes.get("/blockers", response_class=HTMLResponse)
def blockers(request: Request, page: int = 1, client: str | None = None, installation: str | None = None, field: str | None = None, blocker_type: str | None = None, status: str = "OPEN", age: str | None = None, search: str | None = None, agent_id: int | None = None, product_code: str | None = None):
session, auth_data, redirect = page_auth(request)
+1 -1
View File
@@ -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",
+83
View File
@@ -0,0 +1,83 @@
"""Administrative CLI: publish a built Agent package (scripts/build_agent_package.py
output) as an AgentRelease, ready to be downloaded/enrolled from the panel.
Like register_agent.py, this is a one-time, operator-run action against
DATABASE_URL directly — never a browser upload endpoint, so the release
binary never has to pass through an HTTP request body. The zip is copied
into the Hub's local release storage (AGENT_RELEASE_STORAGE_PATH), never
into PostgreSQL; only its metadata (version, sha256, size, path) is a row.
Usage:
python central/scripts/publish_agent_release.py --zip dist/GTIFiscalAgent-1.0.0.zip \\
--notes "First distributable build with secure enrollment"
Refuses to publish the same version+platform+architecture twice with a
different sha256 (an accidental respin must get a new version). Publishing
the exact same bytes again is a no-op (idempotent).
"""
import argparse
import hashlib
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "central"))
from sqlalchemy.orm import Session # noqa: E402
from app import release_storage # noqa: E402
from app.db import engine # noqa: E402
from app.models import AgentRelease # noqa: E402
def publish(session, zip_path: Path, *, platform="windows", architecture="x64", notes=None, created_by=None, version=None):
data = zip_path.read_bytes()
digest = hashlib.sha256(data).hexdigest()
version = version or _version_from_manifest(zip_path)
existing = session.query(AgentRelease).filter_by(version=version, platform=platform, architecture=architecture).one_or_none()
if existing:
if existing.sha256 == digest:
return existing, False # identical content already published: idempotent no-op
raise SystemExit(f"refused: {version}/{platform}/{architecture} is already published with a different sha256 "
f"({existing.sha256[:12]}... != {digest[:12]}...) — bump the version instead of overwriting it")
filename = release_storage.release_filename(version, platform, architecture)
dest = release_storage.storage_root() / filename
dest.write_bytes(data)
release = AgentRelease(version=version, platform=platform, architecture=architecture, filename=filename,
size_bytes=len(data), sha256=digest, storage_path=filename, status="PUBLISHED",
release_notes=notes, created_by=created_by)
session.add(release)
session.commit()
return release, True
def _version_from_manifest(zip_path: Path) -> str:
# The package name is `GTIFiscalAgent-<version>.zip`; avoids depending on
# zip internals just to learn the version this script itself is publishing.
stem = zip_path.stem
prefix = "GTIFiscalAgent-"
if not stem.startswith(prefix):
raise SystemExit(f"cannot infer version from filename {zip_path.name!r} — pass --version explicitly")
return stem[len(prefix):]
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--zip", required=True, type=Path, help="Path to the built package zip (scripts/build_agent_package.py output)")
parser.add_argument("--version", default=None, help="Override the version instead of inferring it from the filename")
parser.add_argument("--platform", default="windows")
parser.add_argument("--architecture", default="x64")
parser.add_argument("--notes", default=None, help="Release notes shown in the panel")
args = parser.parse_args()
if not args.zip.exists():
raise SystemExit(f"not found: {args.zip}")
with Session(engine) as session:
release, created = publish(session, args.zip, platform=args.platform, architecture=args.architecture, notes=args.notes, version=args.version)
print(f"{'published' if created else 'already published (idempotent)'}: version={release.version} "
f"platform={release.platform} architecture={release.architecture} sha256={release.sha256} size_bytes={release.size_bytes}")
if __name__ == "__main__":
main()
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Nova instalação | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading"><div><p class="eyebrow">DISTRIBUIÇÃO DO AGENT</p><h1>Nova instalação</h1><p class="muted">Cria uma instalação pendente; o pacote e o vínculo de enrollment só existem depois de gerados aqui.</p></div><div><a href="/agents" class="secondary">Voltar</a></div></div>
<section class="panel" style="max-width:560px">
{% if error %}<p class="error" role="alert">{{ error }}</p>{% endif %}
{% if not releases %}
<p class="muted">Nenhuma versão do Agent está publicada ainda. Publique uma release primeiro (<code>python central/scripts/publish_agent_release.py</code>) e ela aparecerá aqui.</p>
{% else %}
<form method="post" action="/agents/new">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Cliente
<select name="tenant_id" required>
<option value="">Selecione</option>
{% for tenant in tenants %}<option value="{{ tenant.id }}">{{ tenant.name }}</option>{% endfor %}
</select>
</label>
<label>Nome da instalação
<input name="display_name" placeholder="ex. Servidor Principal" maxlength="160" required>
</label>
<label>Hostname esperado (opcional)
<input name="expected_hostname" placeholder="ex. SRV-CONSTRUCAO" maxlength="255">
</label>
<label>Versão do Agent
<select name="release_id" required>
{% for release in releases %}<option value="{{ release.id }}" {% if loop.first %}selected{% endif %}>{{ release.version }} ({{ release.platform }}/{{ release.architecture }})</option>{% endfor %}
</select>
</label>
<p><button type="submit">Criar instalação</button></p>
</form>
{% endif %}
</section>
{% endblock %}
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}{{ enr.display_name }} | Instalação | GTI Fiscal{% endblock %}
{% block content %}
<div class="detail-head"><div><p class="eyebrow">DISTRIBUIÇÃO DO AGENT</p><h1>{{ enr.display_name }}</h1><p class="muted">{{ tenant.name if tenant else "Cliente não encontrado" }} · release {{ release.version if release else "?" }}</p></div><div><span class="badge">{{ enr.status }}</span></div></div>
{% if download_link %}
<section class="panel">
<h2>Link temporário gerado</h2>
<p class="muted">Mostrado uma única vez, uso único, expira em pouco tempo. Copie agora — ele não pode ser recuperado depois.</p>
<p><code style="word-break:break-all">{{ download_link }}</code></p>
</section>
{% endif %}
<section class="panel">
<h2>Detalhes</h2>
<table>
<tr><th>Cliente</th><td>{{ tenant.name if tenant else "-" }}</td></tr>
<tr><th>Nome</th><td>{{ enr.display_name }}</td></tr>
<tr><th>Hostname esperado</th><td>{{ enr.expected_hostname or "não informado" }}</td></tr>
<tr><th>Versão do Agent</th><td>{{ release.version if release else "-" }} ({{ release.platform if release else "" }}/{{ release.architecture if release else "" }})</td></tr>
<tr><th>Status</th><td><span class="badge">{{ enr.status }}</span>{% if enr.hostname_mismatch %} <span class="badge critical">hostname divergente</span>{% endif %}</td></tr>
<tr><th>Criada em</th><td>{{ enr.created_at }}</td></tr>
<tr><th>Baixada em</th><td>{{ enr.downloaded_at or "ainda não" }}</td></tr>
<tr><th>Hostname visto</th><td>{{ enr.hostname_seen or "-" }}</td></tr>
{% if installed_agent %}
<tr><th>Agent</th><td><a href="/agents/{{ installed_agent.id }}">#{{ installed_agent.id }}</a> · {{ "ativo" if installed_agent.enabled else "desativado" }} · último heartbeat: {{ installed_agent.last_seen_at or "nenhum ainda" }}</td></tr>
{% endif %}
</table>
</section>
<section class="panel">
<h2>Ações</h2>
{% if enr.status not in ("ENROLLED", "REVOKED") %}
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/link" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Gerar link temporário</button>
</form>
<a href="/agents/pending/{{ enr.enrollment_uuid }}/download" class="botao">Baixar Agent agora</a>
{% endif %}
{% if enr.status != "REVOKED" %}
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/revoke" style="display:inline" onsubmit="return confirm('Revogar esta instalação? Links e o Agent (se já ativo) deixam de funcionar.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Revogar</button>
</form>
{% endif %}
{% if enr.status == "ENROLLED" and installed_agent %}
<a href="/agents/{{ installed_agent.id }}" class="secondary">Ver Agent</a>
{% endif %}
</section>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Releases do Agent | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading"><div><p class="eyebrow">DISTRIBUIÇÃO DO AGENT</p><h1>Versões do Agent</h1><p class="muted">O binário é universal por versão; publicação é feita por script (<code>central/scripts/publish_agent_release.py</code>), nunca por upload no navegador.</p></div><div><a href="/agents/new" class="botao">+ Nova instalação</a> <a href="/agents" class="secondary">Voltar</a></div></div>
<div class="panel table-wrap">
<table>
<caption>Releases catalogadas</caption>
<thead><tr><th>Versão</th><th>Plataforma</th><th>Status</th><th>SHA-256</th><th>Tamanho</th><th>Publicada em</th><th>Instalações</th><th></th></tr></thead>
<tbody>
{% for release in releases %}
<tr>
<td><b>{{ release.version }}</b></td>
<td>{{ release.platform }}/{{ release.architecture }}</td>
<td><span class="badge {{ 'critical' if release.status == 'REVOKED' else '' }}">{{ release.status }}</span></td>
<td><code>{{ release.sha256[:12] }}…</code></td>
<td>{{ "%.1f"|format(release.size_bytes / 1048576) }} MB</td>
<td>{{ release.published_at or "não publicada" }}</td>
<td>{{ installation_counts.get(release.id, 0) }}</td>
<td>
{% if release.status == "PUBLISHED" %}
<form method="post" action="/agents/releases/{{ release.id }}/revoke" onsubmit="return confirm('Revogar a release {{ release.version }}? Nenhum novo download poderá usá-la.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Revogar</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="8" class="empty-state">Nenhuma release publicada ainda.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+2 -1
View File
@@ -1,7 +1,8 @@
{% extends "base.html" %}
{% block title %}Agents | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading"><div><p class="eyebrow">INVENTÁRIO OPERACIONAL</p><h1>Agents / Servidores</h1><p class="muted">Identidade do cliente primeiro; IDs técnicos ficam no detalhe.</p></div><form class="filters" method="get"><label>Cliente, instalação ou hostname<input name="search" value="{{ search }}"></label><label>Saúde técnica<select name="health"><option value="">Todos</option>{% for value in ["ONLINE","DEGRADED","OFFLINE","FAILED","UNKNOWN"] %}<option value="{{ value }}" {% if health_filter==value %}selected{% endif %}>{{ value|title }}</option>{% endfor %}</select></label><label>Ordenação<select name="sort"><option value="display_name" {% if sort=="display_name" %}selected{% endif %}>Nome de exibição</option><option value="worst_coverage" {% if sort=="worst_coverage" %}selected{% endif %}>Pior cobertura fiscal</option></select></label><button class="secondary" type="submit">Filtrar</button></form></div>
<div class="page-heading"><div><p class="eyebrow">INVENTÁRIO OPERACIONAL</p><h1>Agents / Servidores</h1><p class="muted">Identidade do cliente primeiro; IDs técnicos ficam no detalhe.</p></div><div><a href="/agents/new" class="botao">+ Nova instalação</a> <a href="/agents/releases" class="secondary">Releases</a></div></div>
<div class="page-heading"><form class="filters" method="get"><label>Cliente, instalação ou hostname<input name="search" value="{{ search }}"></label><label>Saúde técnica<select name="health"><option value="">Todos</option>{% for value in ["ONLINE","DEGRADED","OFFLINE","FAILED","UNKNOWN"] %}<option value="{{ value }}" {% if health_filter==value %}selected{% endif %}>{{ value|title }}</option>{% endfor %}</select></label><label>Ordenação<select name="sort"><option value="display_name" {% if sort=="display_name" %}selected{% endif %}>Nome de exibição</option><option value="worst_coverage" {% if sort=="worst_coverage" %}selected{% endif %}>Pior cobertura fiscal</option></select></label><button class="secondary" type="submit">Filtrar</button></form></div>
<div class="panel table-wrap"><table><caption>Instalações monitoradas</caption><thead><tr><th>Cliente</th><th>Nome de exibição</th><th>Hostname</th><th>Saúde técnica</th><th>Status fiscal</th><th>Cobertura fiscal</th><th>Produtos</th><th>Versão</th><th>Último heartbeat</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td><a href="/agents/{{ item.agent_id }}"><b>{{ item.client or "Cliente não identificado" }}</b></a></td><td>{{ item.display_name or "Não informado" }}</td><td>{{ item.hostname or "Não informado" }}</td><td><span class="badge {{ item.technical_health|lower }}">Saúde técnica: {{ item.technical_health_label }}</span></td><td><span class="badge fiscal-{{ item.fiscal_status|lower }}">Status fiscal: {{ item.fiscal_status_label }}</span><small>{{ item.fiscally_complete }} completos · {{ item.safe_blocked }} bloqueados com segurança · {{ item.failed_products }} falhas · {{ item.unclassified_products }} não classificados</small>{% if not item.coverage_invariant_pass %}<small class="error">Inconsistência: {{ item.coverage_overcount_products }} acima do total{% if item.coverage_invalid_counts %}; contagens negativas{% endif %}</small>{% endif %}</td><td><strong>{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%</strong><small>{{ item.fiscally_complete }} / {{ item.total_products }} completos</small></td><td>{{ item.total_products }}<small>{{ item.failed_products }} falhas técnicas</small></td><td>{{ item.agent_version_display }}</td><td title="{{ item.last_seen_at }}">{{ item.last_seen_display }}<small>{{ item.last_seen_age }}</small></td></tr>{% else %}<tr><td colspan="9" class="empty-state">Nenhuma instalação encontrada.</td></tr>{% endfor %}</tbody></table></div>
<nav class="pagination" aria-label="Paginação de agents">{% if result.page > 1 %}<a href="/agents?page={{ result.page - 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} instalações</span>{% if result.page * result.page_size < result.total %}<a href="/agents?page={{ result.page + 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Próxima</a>{% endif %}</nav>
{% endblock %}
+359
View File
@@ -0,0 +1,359 @@
"""Agent distribution: releases + secure enrollment.
Covers central/app/agent_distribution.py, enrollment.py, release_storage.py,
rate_limit.py, scripts/publish_agent_release.py and the new admin pages in
web.py. Security tests mirror the mission's checklist: expired/revoked/
second-use/tampered download links, expired/replayed/wrong-installation/
already-enrolled enrollment attempts, path traversal, rate limiting, and
that no secret ever reaches the audit log.
"""
import dataclasses
import io
import json
import sys
import tempfile
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from central.app import config, enrollment, rate_limit, release_storage
from central.app.main import create_app
from central.app.models import Agent, AgentEnrollment, AgentRelease, AuditEvent, Installation, Tenant
from central.scripts.publish_agent_release import publish
from central.tests.test_control_plane import seed, setup_db
VERSION = "1.2.3"
def make_release_zip(version=VERSION) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr(f"GTIFiscalAgent-{version}/gti-agent.ps1", "# fake install script\n")
z.writestr(f"GTIFiscalAgent-{version}/VERSION", version)
return buf.getvalue()
def write_temp_zip(data: bytes, version=VERSION) -> Path:
path = Path(tempfile.gettempdir()) / f"GTIFiscalAgent-{version}-{id(data)}.zip"
path.write_bytes(data)
return path
@pytest.fixture(autouse=True)
def clear_rate_limit():
rate_limit._attempts.clear()
yield
rate_limit._attempts.clear()
@pytest.fixture
def hub(tmp_path, monkeypatch):
replaced = dataclasses.replace(
config.settings,
agent_release_storage_path=str(tmp_path / "releases"),
agent_download_link_ttl_seconds=1800,
agent_download_max_uses_default=1,
agent_enrollment_ttl_seconds=86400,
base_url="https://gtifiscal.example.gti",
web_cookie_secure=False,
)
monkeypatch.setattr(config, "settings", replaced)
# publish_agent_release.py is written to also run standalone (`python
# central/scripts/publish_agent_release.py`), so it imports via a
# sys.path-inserted "app" package (same convention as register_agent.py)
# rather than "central.app" — a second, independent copy of every module
# it touches, including config. Patch that copy's settings too, or the
# release it publishes lands outside this test's tmp_path.
if "app.config" in sys.modules:
monkeypatch.setattr(sys.modules["app.config"], "settings", replaced)
factory = setup_db()
seed(factory) # tenant "Acme" + Installation "srv-01" + admin "operator"/"a-secure-password"
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
return client, factory
def login(client):
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
assert response.status_code == 303
def csrf(client):
return client.cookies.get("gti_csrf", "")
def publish_release(factory, version=VERSION):
with factory() as session:
release, _created = publish(session, write_temp_zip(make_release_zip(version), version), version=version)
return release.id
def tenant_id(factory):
with factory() as session:
return session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
def audits(factory):
with factory() as session:
return session.scalars(select(AuditEvent)).all()
def bundle_enrollment_fields(content: bytes) -> dict:
with zipfile.ZipFile(io.BytesIO(content)) as z:
[entry] = [n for n in z.namelist() if n.endswith("enrollment.json")]
return json.loads(z.read(entry))
# ---------- publish_agent_release.py ----------
def test_publish_is_idempotent_and_rejects_hash_conflict(hub, tmp_path):
_client, factory = hub
with factory() as session:
zip_bytes = make_release_zip("9.9.9")
release, created = publish(session, write_temp_zip(zip_bytes, "9.9.9"), version="9.9.9")
assert created and release.status == "PUBLISHED" and release.version == "9.9.9"
release2, created2 = publish(session, write_temp_zip(zip_bytes, "9.9.9"), version="9.9.9")
assert not created2 and release2.id == release.id # identical bytes: no-op
with factory() as session:
with pytest.raises(SystemExit):
publish(session, write_temp_zip(make_release_zip("9.9.9") + b"\x00", "9.9.9"), version="9.9.9")
def test_published_release_binary_never_stored_in_postgres(hub):
_client, factory = hub
release_id = publish_release(factory)
with factory() as session:
release = session.get(AgentRelease, release_id)
assert release.storage_path == Path(release.storage_path).name # a bare filename, never a path with directories
path = release_storage.safe_path_for(release.storage_path)
assert path.exists() and path.read_bytes() # binary lives on disk, outside the DB
# ---------- path traversal ----------
def test_release_storage_path_traversal_impossible(hub):
for hostile in ("../../etc/passwd", "sub/dir.zip", "/etc/passwd", "..\\..\\windows\\system32", ""):
with pytest.raises(ValueError):
release_storage.safe_path_for(hostile)
# ---------- full happy path: create -> link -> download -> enroll ----------
def test_full_installation_flow_creates_a_working_agent(hub):
client, factory = hub
release_id = publish_release(factory)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
"display_name": "Servidor Principal", "expected_hostname": "SRV-UNIAO",
"release_id": release_id}, follow_redirects=False)
assert created.status_code == 303
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
linked = client.post(f"/agents/pending/{installation_uuid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
assert linked.status_code == 303
download_url = linked.headers["location"].split("link=", 1)[1]
download_token = download_url.rsplit("/", 1)[-1]
# A technician on the client machine never has an admin session.
anon = TestClient(client.app)
downloaded = anon.get(f"/dl/agent/{download_token}")
assert downloaded.status_code == 200
assert downloaded.headers["content-type"] == "application/octet-stream"
assert "attachment" in downloaded.headers["content-disposition"]
payload = bundle_enrollment_fields(downloaded.content)
assert payload["installation_id"] == installation_uuid
enrolled = anon.post("/v1/agents/enroll", json={
"installation_id": payload["installation_id"], "enrollment_token": payload["enrollment_token"],
"agent_version": "1.2.3", "hostname": "SRV-UNIAO", "platform": "windows", "machine_fingerprint": "abc123",
})
assert enrolled.status_code == 200
body = enrolled.json()
assert body["agent_id"] and body["agent_token"] and body["hostname_mismatch"] is False
with factory() as session:
agent = session.get(Agent, body["agent_id"])
assert agent.enabled and agent.agent_version == "1.2.3"
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
assert installation.installation_id == installation_uuid and installation.hostname == "SRV-UNIAO"
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
assert enr.status == "ENROLLED" and enr.enrollment_token_hash is None and enr.agent_id == agent.id
# Replay: the very same enrollment token can never enroll a second Agent.
replay = anon.post("/v1/agents/enroll", json={"installation_id": payload["installation_id"], "enrollment_token": payload["enrollment_token"]})
assert replay.status_code == 401 and replay.json()["detail"] == "ALREADY_ENROLLED"
# Second use of the (single-use) download link is also rejected.
redownload = anon.get(f"/dl/agent/{download_token}")
assert redownload.status_code == 404
events = {event.action for event in audits(factory)}
assert {"INSTALLATION_CREATED", "DOWNLOAD_LINK_CREATED", "PACKAGE_DOWNLOADED", "ENROLLMENT_SUCCEEDED", "AGENT_CREDENTIAL_ISSUED", "ENROLLMENT_REJECTED"} <= events
secrets_leaked = [download_token, payload["enrollment_token"], body["agent_token"]]
for event in audits(factory):
haystack = json.dumps({"action": event.action, "target": event.target_id, "context": event.context})
for secret in secrets_leaked:
assert secret not in haystack
def test_direct_authenticated_download_does_not_need_a_link(hub):
client, factory = hub
release_id = publish_release(factory)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
"display_name": "Loja 2", "release_id": release_id}, follow_redirects=False)
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
downloaded = client.get(f"/agents/pending/{installation_uuid}/download")
assert downloaded.status_code == 200
payload = bundle_enrollment_fields(downloaded.content)
assert payload["installation_id"] == installation_uuid
# ---------- download-link security ----------
def _pending(factory, release_id, **overrides):
import uuid as uuid_mod
with factory() as session:
enr = AgentEnrollment(enrollment_uuid=str(uuid_mod.uuid4()), tenant_id=tenant_id(factory),
display_name="Teste", release_id=release_id, status="PENDING", **overrides)
session.add(enr); session.commit()
return enr.enrollment_uuid
def test_expired_download_link_rejected(hub):
client, factory = hub
release_id = publish_release(factory)
uid = _pending(factory, release_id)
token = enrollment.new_token()
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uid))
enr.download_token_hash = enrollment.token_digest(token)
enr.download_token_expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
enr.download_max_uses = 1
session.commit()
assert client.get(f"/dl/agent/{token}").status_code == 404
def test_revoked_installation_link_rejected(hub):
client, factory = hub
release_id = publish_release(factory)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
"display_name": "Teste", "release_id": release_id}, follow_redirects=False)
uid = created.headers["location"].rsplit("/", 1)[-1]
linked = client.post(f"/agents/pending/{uid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
token = linked.headers["location"].split("link=", 1)[1].rsplit("/", 1)[-1]
client.post(f"/agents/pending/{uid}/revoke", data={"csrf_token": csrf(client)}, follow_redirects=False)
assert client.get(f"/dl/agent/{token}").status_code == 404
def test_wrong_or_random_download_token_rejected(hub):
client, factory = hub
publish_release(factory)
assert client.get("/dl/agent/definitely-not-a-real-token").status_code == 404
def test_release_revoked_blocks_download(hub):
client, factory = hub
release_id = publish_release(factory)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
"display_name": "Teste", "release_id": release_id}, follow_redirects=False)
uid = created.headers["location"].rsplit("/", 1)[-1]
linked = client.post(f"/agents/pending/{uid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
token = linked.headers["location"].split("link=", 1)[1].rsplit("/", 1)[-1]
client.post(f"/agents/releases/{release_id}/revoke", data={"csrf_token": csrf(client)}, follow_redirects=False)
assert client.get(f"/dl/agent/{token}").status_code == 404
# ---------- enrollment security ----------
def _enrollable(factory, release_id, **overrides):
token = enrollment.new_token()
uid = _pending(factory, release_id)
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uid))
enr.enrollment_token_hash = enrollment.token_digest(token)
enr.enrollment_token_expires_at = overrides.pop("expires_at", datetime.now(timezone.utc) + timedelta(hours=1))
for key, value in overrides.items():
setattr(enr, key, value)
session.commit()
return uid, token
def test_expired_enrollment_token_rejected(hub):
client, factory = hub
release_id = publish_release(factory)
uid, token = _enrollable(factory, release_id, expires_at=datetime.now(timezone.utc) - timedelta(seconds=1))
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": token})
assert response.status_code == 401 and response.json()["detail"] == "EXPIRED"
def test_wrong_installation_uuid_rejected(hub):
client, factory = hub
release_id = publish_release(factory)
_uid, token = _enrollable(factory, release_id)
import uuid as uuid_mod
response = client.post("/v1/agents/enroll", json={"installation_id": str(uuid_mod.uuid4()), "enrollment_token": token})
assert response.status_code == 401 and response.json()["detail"] == "INVALID_INSTALLATION"
def test_malformed_installation_id_rejected(hub):
client, _factory = hub
response = client.post("/v1/agents/enroll", json={"installation_id": "not-a-uuid", "enrollment_token": "x"})
assert response.status_code == 401 and response.json()["detail"] == "INVALID_INSTALLATION"
def test_random_enrollment_token_rejected(hub):
client, factory = hub
release_id = publish_release(factory)
uid, _token = _enrollable(factory, release_id)
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": enrollment.new_token()})
assert response.status_code == 401 and response.json()["detail"] == "INVALID_TOKEN"
def test_revoked_installation_enroll_rejected(hub):
client, factory = hub
release_id = publish_release(factory)
uid, token = _enrollable(factory, release_id, status="REVOKED")
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": token})
assert response.status_code == 401 and response.json()["detail"] == "REVOKED"
def test_hostname_mismatch_is_flagged_but_not_blocked(hub):
client, factory = hub
release_id = publish_release(factory)
uid, token = _enrollable(factory, release_id, expected_hostname="SRV-EXPECTED")
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": token, "hostname": "SRV-OUTRO"})
assert response.status_code == 200 and response.json()["hostname_mismatch"] is True
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uid))
assert enr.hostname_mismatch is True and enr.status == "ENROLLED"
# ---------- rate limiting ----------
def test_enroll_rate_limited_after_repeated_invalid_attempts(hub):
client, _factory = hub
for _ in range(rate_limit.MAX_ATTEMPTS):
response = client.post("/v1/agents/enroll", json={"installation_id": "not-a-uuid", "enrollment_token": "x"})
assert response.status_code == 401
blocked_response = client.post("/v1/agents/enroll", json={"installation_id": "not-a-uuid", "enrollment_token": "x"})
assert blocked_response.status_code == 429
def test_rate_limit_buckets_are_independent_per_ip(hub):
# Unit-level proof that the limiter itself keys on (bucket, ip) rather than a
# shared global counter — the end-to-end TestClient can't vary the caller's
# apparent IP (that requires the real ProxyHeadersMiddleware, wired up only
# under uvicorn --proxy-headers in production, per FORWARDED_ALLOW_IPS).
for _ in range(rate_limit.MAX_ATTEMPTS):
rate_limit.record_attempt("enroll", "198.51.100.1")
assert rate_limit.blocked("enroll", "198.51.100.1")
assert not rate_limit.blocked("enroll", "198.51.100.2")
@@ -0,0 +1,63 @@
"""Static checks on deploy/agent-windows/gti-agent.ps1's `install-auto` command
(the zero-touch path driven by the enrollment bundle from the panel). No
Windows host is available in CI, so these are text-level guarantees — the
same properties are exercised for real in the manual Windows smoke test
described in docs/PILOT_DEPLOYMENT.md.
"""
import re
from pathlib import Path
SCRIPT = (Path(__file__).parents[2] / "deploy" / "agent-windows" / "gti-agent.ps1").read_text(encoding="utf-8")
def install_auto_block() -> str:
start = SCRIPT.index("'install-auto' {")
end = SCRIPT.index("'configure' {")
return SCRIPT[start:end]
def test_install_auto_is_a_registered_command():
validate_set = re.search(r"ValidateSet\(([^)]*)\)", SCRIPT).group(1)
assert "'install-auto'" in validate_set
def test_install_auto_never_enables_firebird_writes():
block = install_auto_block()
assert "FIREBIRD_WRITES=0" in block
assert "FIREBIRD_WRITES=1" not in SCRIPT # nowhere in the whole script, not just this block
def test_install_auto_refuses_a_non_https_hub():
assert "hub_url -notmatch '^https://'" in install_auto_block()
def test_install_auto_verifies_package_sha256_before_installing():
block = install_auto_block()
assert "SHA256_VALIDATION" in block and "Get-FileHash" in block
assert block.index("SHA256_VALIDATION") < block.index("Invoke-RestMethod") # integrity checked before any network call
def test_install_auto_deletes_the_enrollment_bundle_after_use():
block = install_auto_block()
assert "Remove-Item $EnrollmentFile -Force" in block
assert block.index("Remove-Item $EnrollmentFile") > block.index("Invoke-RestMethod") # only after a successful enroll
def test_install_auto_registers_but_never_starts_the_service():
block = install_auto_block()
assert "install" in block and "Start-Service" not in block # Firebird config is still missing; starting it is a separate, explicit step
def test_install_auto_never_hardcodes_a_client_firebird_path():
block = install_auto_block()
assert "'FIREBIRD_DATABASE_PATH='" in block # written, but as an empty placeholder (no real path baked in)
def test_install_auto_restricts_the_env_file_acl():
block = install_auto_block()
assert "icacls $EnvFile /inheritance:r /grant:r 'Administrators:(F)' 'SYSTEM:(F)'" in block
def test_install_auto_is_idempotent_when_already_enrolled():
block = install_auto_block()
assert "Already enrolled and installed" in block and "exit 0" in block
+1 -1
View File
@@ -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):