installer v1 (A+B): install-code enrollment, panel code UI, EXE publish; DPAPI credential store
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user