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
+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)