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