Files
GTI_GERENCIADOR_FISCAL/central/app/agent_distribution.py
T
gtiandClaude Sonnet 5 ac7b503c2f fix: persist and recover the Agent's real installation_id (heartbeat activation)
POST /v1/agents/enroll now returns installation_id; new installs persist it
to agent.env immediately. Runtime recovery precedence: existing SQLite
installation_identity -> configured INSTALLATION_ID -> authenticated
GET /v1/agents/me/identity. Recovery fails closed on missing/malformed/
mismatched identity rather than inventing a UUID (which the Hub would then
reject as a conflicting Installation on an already-enrolled Agent). An
explicit AGENT_JOBS_ENABLED gate stops the service from claiming a job right
after the first heartbeat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 22:13:56 -03:00

255 lines
14 KiB
Python

"""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:
"""Serve the release artifact for the installation — always the same
universal release binary (ZIP bundle for legacy token flow, bare EXE for
the installer flow). Audit the download; never embed the enrollment token
in an EXE download (the operator types a standalone install code instead)."""
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")
now = datetime.now(timezone.utc)
is_exe = release.filename.lower().endswith(".exe")
if is_exe:
# Universal EXE flow: no per-installation bundle, no enrollment.json.
# The operator types the standalone install code into Setup.exe and the
# Hub resolves the installation from the code hash alone.
enr.downloaded_at = now
if enr.status not in ("ENROLLED", "REVOKED"):
enr.status = "DOWNLOADED"
record_audit(session, admin_user_id=actor_admin_id, tenant_id=enr.tenant_id, action="INSTALLER_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 = release.filename
return Response(content=path.read_bytes(), media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"',
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-store"})
token = enrollment.new_token()
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")
install_code = payload.get("install_code")
# Adjustment: install_code and enrollment_token are mutually exclusive —
# the EXE flow sends a code only; sending both is a request error.
if install_code and (supplied_token or payload.get("installation_id")):
record_attempt("enroll", ip)
with session_factory() as session:
record_audit(session, admin_user_id=None, tenant_id=None, action="ENROLLMENT_REJECTED",
target_type="agent_enrollment", target_id="-",
context={"ip": ip, "reason": "MIXED_CREDENTIALS"}, severity="WARNING")
session.commit()
raise HTTPException(400, "MIXED_CREDENTIALS")
with session_factory() as session:
enr = None
try:
if install_code:
enr = _resolve_by_install_code(session, ip, install_code)
else:
enr = _resolve_by_legacy_token(session, ip, installation_id, supplied_token)
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")
release = session.get(AgentRelease, enr.release_id)
if release is None or release.status != "PUBLISHED":
raise enrollment.EnrollmentRejected("RELEASE_UNAVAILABLE")
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id, action="ENROLLMENT_STARTED",
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
context={"ip": ip, "credential_type": "install_code" if install_code else "enrollment_token"})
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 = datetime.now(timezone.utc)
enr.enrollment_token_hash = None # replay-proof even inside its TTL window
enr.install_code_hash = None # consumed: truly one-time, replay-proof
enr.install_code_expires_at = None
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,
"installation_id": enr.enrollment_uuid,
"hub_url": config.settings.base_url, "hostname_mismatch": mismatch}
except enrollment.EnrollmentRejected as rejected:
session.rollback()
record_attempt("enroll", ip)
reason = rejected.reason
if enr is not None:
enr.install_code_attempts = (enr.install_code_attempts or 0) + 1
record_attempt(f"enroll_code:{enr.enrollment_uuid}", ip)
session.commit()
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=enr.enrollment_uuid if enr else (installation_id or "-"),
context={"ip": ip, "reason": reason}, severity="WARNING")
session.commit()
raise HTTPException(401, reason)
def _resolve_by_install_code(session, ip, code):
"""Resolve the Installation by the install_code hash alone. Returns the
enrollment or None. Format/unknown codes and expired codes raise
EnrollmentRejected. Per-installation and per-IP rate limits apply."""
try:
digest = enrollment.install_code_digest(code)
except ValueError:
raise enrollment.EnrollmentRejected("INVALID_INSTALL_CODE")
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.install_code_hash == digest))
if enr is None:
raise enrollment.EnrollmentRejected("INVALID_INSTALL_CODE")
# Per-installation rate limit: a hostile caller guessing codes for one
# installation must burn the whole budget even after the code rotates.
if blocked(f"enroll_code:{enr.enrollment_uuid}", ip):
raise enrollment.EnrollmentRejected("RATE_LIMITED")
now = datetime.now(timezone.utc)
if not enr.install_code_expires_at or _aware(enr.install_code_expires_at) < now:
raise enrollment.EnrollmentRejected("EXPIRED")
return enr
def _resolve_by_legacy_token(session, ip, installation_id, supplied_token):
"""Legacy enrollment_token path (published ZIP bundles). Returns the
enrollment or raises EnrollmentRejected. Order preserved from the
original flow: enrolled-again must read ALREADY_ENROLLED, not
INVALID_TOKEN."""
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")
return enr
return routes