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:
+143
-3
@@ -1,7 +1,8 @@
|
||||
"""Server-rendered operator pages for the GTI Fiscal control panel."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
@@ -9,14 +10,14 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import config, sso
|
||||
from . import agent_distribution, config, enrollment, sso
|
||||
from .config import settings
|
||||
from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context
|
||||
from .context_keys import ALLOWED_KEYS, ENUMS
|
||||
from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, list_blockers, list_installations, list_jobs, list_panel_events
|
||||
from .human_auth import create_session, current_session, require_csrf, verify_password
|
||||
from .panel_presentation import format_ui_datetime
|
||||
from .models import AdminUser, Agent, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant
|
||||
from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant
|
||||
from .jobs import create_job
|
||||
from .audit import record_audit
|
||||
|
||||
@@ -207,6 +208,145 @@ def router(session_factory):
|
||||
return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/releases", response_class=HTMLResponse)
|
||||
def agent_releases(request: Request):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
releases = session.scalars(select(AgentRelease).order_by(AgentRelease.created_at.desc())).all()
|
||||
counts = {}
|
||||
for release_id, in session.execute(select(AgentEnrollment.release_id)).all():
|
||||
counts[release_id] = counts.get(release_id, 0) + 1
|
||||
return render(request, "agent_releases.html", {"releases": releases, "installation_counts": counts, "auth": auth_data})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/releases/{release_id}/revoke")
|
||||
async def revoke_release(request: Request, release_id: int):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
values = await form_values(request); web_session, admin = auth_data
|
||||
require_csrf(web_session, values.get("csrf_token"))
|
||||
release = session.get(AgentRelease, release_id)
|
||||
if not release: return HTMLResponse("Release not found", status_code=404)
|
||||
release.status = "REVOKED"
|
||||
record_audit(session, admin_user_id=admin.id, action="AGENT_RELEASE_REVOKED", target_type="agent_release", target_id=release.version, context={"platform": release.platform, "architecture": release.architecture})
|
||||
session.commit()
|
||||
return RedirectResponse("/agents/releases", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/new", response_class=HTMLResponse)
|
||||
def new_installation_form(request: Request):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all()
|
||||
releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all()
|
||||
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": None})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/new")
|
||||
async def create_pending_installation(request: Request):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
values = await form_values(request); web_session, admin = auth_data
|
||||
require_csrf(web_session, values.get("csrf_token"))
|
||||
tenant_id = values.get("tenant_id")
|
||||
display_name = (values.get("display_name") or "").strip()[:160]
|
||||
expected_hostname = (values.get("expected_hostname") or "").strip()[:255] or None
|
||||
release_id = values.get("release_id")
|
||||
errors = []
|
||||
tenant = session.get(Tenant, int(tenant_id)) if tenant_id and tenant_id.isdigit() else None
|
||||
release = session.get(AgentRelease, int(release_id)) if release_id and release_id.isdigit() else None
|
||||
if not tenant: errors.append("Selecione um cliente válido.")
|
||||
if not display_name: errors.append("Informe um nome para a instalação.")
|
||||
if not release or release.status != "PUBLISHED": errors.append("Selecione uma versão publicada do Agent.")
|
||||
if errors:
|
||||
tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all()
|
||||
releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all()
|
||||
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": " ".join(errors)}, 400)
|
||||
enrollment_uuid = str(uuid.uuid4())
|
||||
enr = AgentEnrollment(enrollment_uuid=enrollment_uuid, tenant_id=tenant.id, display_name=display_name, expected_hostname=expected_hostname, release_id=release.id, status="PENDING", created_by=admin.id)
|
||||
session.add(enr)
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=tenant.id, action="INSTALLATION_CREATED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"display_name": display_name, "release_version": release.version})
|
||||
session.commit()
|
||||
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
def _pending_or_404(session, enrollment_uuid):
|
||||
return session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == enrollment_uuid))
|
||||
|
||||
@routes.get("/agents/pending/{enrollment_uuid}", response_class=HTMLResponse)
|
||||
def pending_installation_detail(request: Request, enrollment_uuid: str, link: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
enr = _pending_or_404(session, enrollment_uuid)
|
||||
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
||||
release = session.get(AgentRelease, enr.release_id)
|
||||
tenant = session.get(Tenant, enr.tenant_id)
|
||||
installed_agent = session.get(Agent, enr.agent_id) if enr.agent_id else None
|
||||
return render(request, "agent_pending_detail.html", {"enr": enr, "release": release, "tenant": tenant, "installed_agent": installed_agent, "download_link": link, "auth": auth_data})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/pending/{enrollment_uuid}/link")
|
||||
async def create_download_link(request: Request, enrollment_uuid: str):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
values = await form_values(request); web_session, admin = auth_data
|
||||
require_csrf(web_session, values.get("csrf_token"))
|
||||
enr = _pending_or_404(session, enrollment_uuid)
|
||||
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
||||
if enr.status in ("ENROLLED", "REVOKED"): return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
|
||||
token = enrollment.new_token()
|
||||
enr.download_token_hash = enrollment.token_digest(token)
|
||||
enr.download_token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=config.settings.agent_download_link_ttl_seconds)
|
||||
enr.download_max_uses = config.settings.agent_download_max_uses_default
|
||||
enr.download_uses = 0
|
||||
enr.status = "LINK_CREATED"
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="DOWNLOAD_LINK_CREATED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"ttl_seconds": config.settings.agent_download_link_ttl_seconds, "ip": client_ip(request)})
|
||||
session.commit()
|
||||
url = f"{config.settings.base_url}/dl/agent/{token}"
|
||||
return RedirectResponse(f"/agents/pending/{enrollment_uuid}?link={url}", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/pending/{enrollment_uuid}/download")
|
||||
def direct_download(request: Request, enrollment_uuid: str):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
web_session, admin = auth_data
|
||||
enr = _pending_or_404(session, enrollment_uuid)
|
||||
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
||||
if enr.status == "REVOKED": return HTMLResponse("Installation revoked", status_code=409)
|
||||
try:
|
||||
return agent_distribution.build_and_serve(session, enr, request, actor_admin_id=admin.id)
|
||||
except enrollment.EnrollmentRejected as rejected:
|
||||
return HTMLResponse(f"Download indisponível: {rejected.reason}", status_code=409)
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/pending/{enrollment_uuid}/revoke")
|
||||
async def revoke_pending_installation(request: Request, enrollment_uuid: str):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
values = await form_values(request); web_session, admin = auth_data
|
||||
require_csrf(web_session, values.get("csrf_token"))
|
||||
enr = _pending_or_404(session, enrollment_uuid)
|
||||
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
||||
enr.status = "REVOKED"
|
||||
enr.download_token_hash = None
|
||||
enr.enrollment_token_hash = None
|
||||
if enr.agent_id:
|
||||
agent = session.get(Agent, enr.agent_id)
|
||||
if agent: agent.enabled = False
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"had_agent": bool(enr.agent_id)})
|
||||
session.commit()
|
||||
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/blockers", response_class=HTMLResponse)
|
||||
def blockers(request: Request, page: int = 1, client: str | None = None, installation: str | None = None, field: str | None = None, blocker_type: str | None = None, status: str = "OPEN", age: str | None = None, search: str | None = None, agent_id: int | None = None, product_code: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
|
||||
Reference in New Issue
Block a user