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:
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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