installer v1 (A+B): install-code enrollment, panel code UI, EXE publish; DPAPI credential store

This commit is contained in:
gti
2026-09-23 16:38:19 -03:00
parent b058affa29
commit 91c4030b06
15 changed files with 1135 additions and 54 deletions
+92 -16
View File
@@ -43,17 +43,34 @@ def _aware(value: datetime | None) -> datetime | None:
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."""
"""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")
token = enrollment.new_token()
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
@@ -117,27 +134,38 @@ def router(session_factory):
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 not enrollment.valid_uuid(installation_id):
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_id))
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")
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")
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()
@@ -150,8 +178,10 @@ def router(session_factory):
display_name=enr.display_name, hostname=hostname or None))
enr.status = "ENROLLED"
enr.enrollment_used_at = now
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
@@ -168,10 +198,56 @@ def router(session_factory):
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=installation_id or "-",
context={"ip": ip, "reason": rejected.reason}, severity="WARNING")
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, rejected.reason)
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
+52
View File
@@ -36,6 +36,58 @@ def new_token() -> str:
return secrets.token_urlsafe(32)
# ---- installation code (XXXX-XXXX-XXXX-XXXX) ----
#
# A 16-character Crockford Base32 code = 16 * 5 = 80 bits of entropy. The
# alphabet deliberately excludes I/L/O/U (ambiguous when retyped by a human);
# the same alphabet is used so a code cannot contain those letters.
INSTALL_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
_INSTALL_CODE_GROUPS = 4
_INSTALL_CODE_PER_GROUP = 4
_INSTALL_CODE_LENGTH = _INSTALL_CODE_GROUPS * _INSTALL_CODE_PER_GROUP
# Crockford transliteration for input normalization: O -> 0, I/L -> 1.
_INSTALL_CODE_TRANSLIT = str.maketrans({"O": "0", "I": "1", "L": "1"})
_INSTALL_CODE_RE = re.compile(
r"^[" + re.escape(INSTALL_CODE_ALPHABET) + r"]{" + str(_INSTALL_CODE_LENGTH) + r"}$"
)
def generate_install_code() -> str:
"""Return a fresh installation code formatted XXXX-XXXX-XXXX-XXXX."""
chars = "".join(secrets.choice(INSTALL_CODE_ALPHABET) for _ in range(_INSTALL_CODE_LENGTH))
return "-".join(
chars[i : i + _INSTALL_CODE_PER_GROUP] for i in range(0, _INSTALL_CODE_LENGTH, _INSTALL_CODE_PER_GROUP)
)
def normalize_install_code(text: str | None) -> str:
"""Upper-case, drop hyphen/space separators and map Crockford lookalikes,
so a human-typed code normalizes to the same digest regardless of how it
was entered. Raises ValueError if the result isn't a valid code."""
if not text:
raise ValueError("empty install code")
clean = "".join(ch for ch in text.upper().translate(_INSTALL_CODE_TRANSLIT) if ch.isalnum())
if not _INSTALL_CODE_RE.match(clean):
raise ValueError(f"invalid install code: {text!r}")
return clean
def install_code_digest(code: str) -> str:
"""Hash a normalized install code. Only the hash is ever persisted; the
plaintext code exists only in the operator's clipboard/UI and the EXE's
memory for the brief instant it is sent to the Hub."""
return token_digest(normalize_install_code(code))
def valid_install_code(text: str | None) -> bool:
try:
normalize_install_code(text)
return True
except ValueError:
return False
def token_digest(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
+8
View File
@@ -412,6 +412,14 @@ class AgentEnrollment(Base):
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)
# The install code typed by the operator into the universal EXE installer.
# Only its SHA-256 is stored (normalize → digest), never the plaintext.
# A new code (rotation) rewrites these three fields; the previous code
# becomes unusable the moment a new one is generated.
install_code_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
install_code_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
install_code_attempts: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
install_code_created_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")
+6 -2
View File
@@ -18,8 +18,12 @@ def storage_root() -> Path:
return root
def release_filename(version: str, platform: str, architecture: str) -> str:
return f"GTIFiscalAgent-{version}-{platform}-{architecture}.zip"
def release_filename(version: str, platform: str, architecture: str, extension: str = ".zip") -> str:
return f"GTIFiscalAgent-{version}-{platform}-{architecture}{extension}"
def setup_exe_filename(version: str) -> str:
return f"GTIFiscalAgent-Setup-{version}.exe"
def safe_path_for(storage_path: str) -> Path:
+48 -3
View File
@@ -36,6 +36,14 @@ def router(session_factory):
def client_ip(request):
return request.client.host if request.client else None
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 to
UTC before comparing against datetime.now(timezone.utc)."""
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def set_session_cookies(response, token, csrf, seconds):
cfg = config.settings
response.set_cookie(cfg.web_cookie_name, token, max_age=seconds, httponly=True, secure=cfg.web_cookie_secure, samesite="lax")
@@ -289,7 +297,7 @@ def router(session_factory):
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):
def pending_installation_detail(request: Request, enrollment_uuid: str, link: str | None = None, code: str | None = None):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
@@ -298,7 +306,11 @@ def router(session_factory):
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})
active_code = bool(enr.install_code_hash and enr.install_code_expires_at and _aware(enr.install_code_expires_at) > datetime.now(timezone.utc))
code_expires_at = enr.install_code_expires_at if active_code else None
return render(request, "agent_pending_detail.html", {"enr": enr, "release": release, "tenant": tenant, "installed_agent": installed_agent,
"download_link": link, "install_code": code, "code_active": active_code,
"code_expires_at": code_expires_at, "auth": auth_data})
finally: session.close()
@routes.post("/agents/pending/{enrollment_uuid}/link")
@@ -347,17 +359,50 @@ def router(session_factory):
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)
was_code_active = bool(enr.install_code_hash)
enr.status = "REVOKED"
enr.download_token_hash = None
enr.enrollment_token_hash = None
enr.install_code_hash = None
enr.install_code_expires_at = 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)})
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), "had_install_code": was_code_active})
if was_code_active:
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_CODE_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid, context={})
session.commit()
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
finally: session.close()
@routes.post("/agents/pending/{enrollment_uuid}/code")
async def generate_install_code(request: Request, enrollment_uuid: str):
"""Generate (or rotate) the installation code shown to the operator and
typed into the universal EXE installer. Only the SHA-256 of the
normalized code is ever stored; the plaintext is returned in the HTML
once, to the SSO-authenticated operator who created the installation."""
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)
code = enrollment.generate_install_code()
now = datetime.now(timezone.utc)
enr.install_code_hash = enrollment.install_code_digest(code)
enr.install_code_expires_at = now + timedelta(seconds=config.settings.agent_enrollment_ttl_seconds)
enr.install_code_attempts = 0
enr.install_code_created_at = now
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_CODE_CREATED", target_type="agent_enrollment",
target_id=enrollment_uuid, context={"ttl_seconds": config.settings.agent_enrollment_ttl_seconds, "ip": client_ip(request)})
session.commit()
return RedirectResponse(f"/agents/pending/{enrollment_uuid}?code={code}", 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)