257 lines
15 KiB
Python
257 lines
15 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,
|
|
features_configured=bool(enr.features_selected), corrector_enabled=bool(enr.features_selected and enr.feature_corrector),
|
|
enrichment_mode=(enr.feature_enrichment_mode if enr.features_selected else "DISABLED")))
|
|
|
|
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
|