feat: add self-enrolling Windows Agent package

This commit is contained in:
lussierj committed 2026-08-28 17:50:39 +00:00
1 parent b9b5e5e36f
commit abf5654a25
20 files changed
+1834 -42

No files matched your search

+4
View File
@@ -92,3 +92,7 @@ class AgentEnrollResponse(BaseModel):
data: AgentEnrollCredentials
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class AgentWindowsPackageRequest(AgentEnrollRequest):
"""Short-lived token used to construct a Windows bootstrap package."""
+52 -1
View File
@@ -3,7 +3,7 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from app.agent_enrollment.dependencies import get_agent_enrollment_service
from app.agent_enrollment.exceptions import (
@@ -17,8 +17,15 @@ from app.agent_enrollment.models import (
AgentEnrollmentListResponse,
AgentEnrollRequest,
AgentEnrollResponse,
AgentWindowsPackageRequest,
)
from app.agent_enrollment.service import AgentEnrollmentService
from app.agent_packages.windows import (
AgentWindowsPackageError,
AgentWindowsPackageSourceError,
build_windows_agent_package,
normalize_hub_url,
)
from app.identity import require_permission
@@ -85,6 +92,50 @@ def list_agent_enrollments(
)
@router.post("/api/agent-enrollments/windows-package")
def download_windows_agent_package(
package_request: AgentWindowsPackageRequest,
http_request: Request,
current_user: EnrollmentManager,
) -> Response:
del current_user
browser_origin = http_request.headers.get("origin", "").strip()
try:
hub_url = normalize_hub_url(browser_origin)
except AgentWindowsPackageError as exc:
raise HTTPException(
status_code=400,
detail=(
"Windows Agent package downloads require "
"a valid browser Origin"
),
) from exc
try:
package = build_windows_agent_package(
enrollment_token=package_request.token,
hub_url=hub_url,
)
except AgentWindowsPackageSourceError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except AgentWindowsPackageError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return Response(
content=package,
media_type="application/zip",
headers={
"Cache-Control": "no-store",
"Pragma": "no-cache",
"Content-Disposition": (
'attachment; filename="MinerOpsAgent-Windows.zip"'
),
},
)
@router.post(
"/api/agent/enroll",
response_model=AgentEnrollResponse,
+1
View File
@@ -0,0 +1 @@
"""Downloadable MinerOps Agent deployment packages."""
+164
View File
@@ -0,0 +1,164 @@
"""Build short-lived Windows Agent enrollment packages in memory."""
from __future__ import annotations
import io
import json
import os
import zipfile
from pathlib import Path
from urllib.parse import urlsplit
WINDOWS_PACKAGE_ROOT = "MinerOpsAgent-Windows"
WINDOWS_PACKAGE_FILES = (
"Enroll-MinerOpsAgent.ps1",
"install.ps1",
"uninstall.ps1",
"MinerOpsAgentService.ps1",
"MinerOpsAgentTray.ps1",
"MinerOpsAgentGUI.ps1",
"MinerOpsApi3EnrichmentPoller.ps1",
"MinerOpsPoolPoller.ps1",
"MinerOpsPoolPollerTask.ps1",
"EnrichOneMiner.ps1",
"MinerOpsControl.ps1",
"VERSION",
"README.md",
)
DEFAULT_WINDOWS_AGENT_SOURCE = Path(
os.environ.get(
"MINEROPS_WINDOWS_AGENT_SOURCE",
"/app/agents/windows",
)
)
class AgentWindowsPackageError(RuntimeError):
"""Base Windows package construction error."""
class AgentWindowsPackageSourceError(AgentWindowsPackageError):
"""Canonical Windows Agent source is unavailable or incomplete."""
def normalize_hub_url(value: str) -> str:
candidate = value.strip()
parsed = urlsplit(candidate)
if parsed.scheme not in {"http", "https"}:
raise AgentWindowsPackageError(
"MinerOps Hub URL must use http or https"
)
if not parsed.netloc:
raise AgentWindowsPackageError(
"MinerOps Hub URL must include a host"
)
if parsed.username or parsed.password:
raise AgentWindowsPackageError(
"MinerOps Hub URL must not contain credentials"
)
if parsed.query or parsed.fragment:
raise AgentWindowsPackageError(
"MinerOps Hub URL must not contain query or fragment data"
)
if parsed.path not in {"", "/"}:
raise AgentWindowsPackageError(
"MinerOps Hub URL must be an origin without a path"
)
return f"{parsed.scheme}://{parsed.netloc}"
def build_windows_agent_package(
*,
enrollment_token: str,
hub_url: str,
source_root: Path | None = None,
) -> bytes:
token = enrollment_token.strip()
if not 32 <= len(token) <= 256:
raise AgentWindowsPackageError(
"Enrollment token has an invalid length"
)
normalized_hub_url = normalize_hub_url(hub_url)
root = (
Path(source_root)
if source_root is not None
else DEFAULT_WINDOWS_AGENT_SOURCE
)
if not root.is_dir():
raise AgentWindowsPackageSourceError(
"Canonical Windows Agent source is unavailable"
)
missing = [
name
for name in WINDOWS_PACKAGE_FILES
if not (root / name).is_file()
]
if missing:
raise AgentWindowsPackageSourceError(
"Canonical Windows Agent source is incomplete: "
+ ", ".join(missing)
)
enrollment = {
"hub_url": normalized_hub_url,
"token": token,
}
instructions = """MinerOps Windows Agent
1. Extract this ZIP on the target Windows machine.
2. Open Windows PowerShell as Administrator.
3. Change to the extracted MinerOpsAgent-Windows folder.
4. Run:
.\\Enroll-MinerOpsAgent.ps1
The enrollment credential is short-lived and single-use.
Do not post enrollment.json in chat, tickets, screenshots, or logs.
Delete the ZIP and extracted setup folder after successful installation.
"""
buffer = io.BytesIO()
with zipfile.ZipFile(
buffer,
mode="w",
compression=zipfile.ZIP_DEFLATED,
) as archive:
for name in WINDOWS_PACKAGE_FILES:
archive.write(
root / name,
arcname=f"{WINDOWS_PACKAGE_ROOT}/{name}",
)
archive.writestr(
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json",
json.dumps(
enrollment,
indent=2,
sort_keys=True,
)
+ "\n",
)
archive.writestr(
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt",
instructions,
)
return buffer.getvalue()
@@ -86,6 +86,11 @@ def agent_management_content(can_manage_agents: bool) -> str:
<p id="agentEnrollmentExpires" class="freshness"></p>
<div class="agent-form-actions">
<button id="agentEnrollmentDownload"
class="command-button"
type="button">
Download Windows package
</button>
<button id="agentEnrollmentCopy"
class="quiet-button"
type="button">
@@ -103,9 +108,10 @@ def agent_management_content(can_manage_agents: bool) -> str:
aria-live="polite"></p>
<p class="panel-copy">
The self-enrolling Windows installer is the next deployment
milestone. For now this verifies the complete secure
enrollment workflow.
The Windows package contains the short-lived enrollment
credential and generic MinerOps Agent runtime. Transfer it
only to the intended Agent computer and delete the ZIP after
successful installation.
</p>
</section>
</section>
@@ -29,6 +29,7 @@
"agentEnrollmentResult",
"agentEnrollmentToken",
"agentEnrollmentExpires",
"agentEnrollmentDownload",
"agentEnrollmentCopy",
"agentEnrollmentDismiss",
"agentEnrollmentCopyStatus"
@@ -359,6 +360,75 @@
}
}
async function downloadWindowsPackage() {
const token = el.agentEnrollmentToken.textContent;
if (!token) {
el.agentEnrollmentCopyStatus.textContent =
"No enrollment token is available to download.";
return;
}
el.agentEnrollmentDownload.disabled = true;
el.agentEnrollmentCopyStatus.textContent =
"Building Windows Agent package...";
try {
const response = await fetch(
"/api/agent-enrollments/windows-package",
{
method: "POST",
credentials: "same-origin",
cache: "no-store",
headers: {
"Accept": "application/zip",
"Content-Type": "application/json"
},
body: JSON.stringify({
token: token
})
}
);
if (!response.ok) {
const payload = await response.json().catch(function () {
return {};
});
throw new Error(
payload && payload.detail
? String(payload.detail)
: "Package request failed with status " +
response.status + "."
);
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = "MinerOpsAgent-Windows.zip";
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
dismissToken();
el.agentCreateStatus.textContent =
"Windows Agent package downloaded. " +
"The on-screen token has been cleared.";
} catch (error) {
el.agentEnrollmentCopyStatus.textContent =
error.message || "Windows package could not be downloaded.";
} finally {
el.agentEnrollmentDownload.disabled = false;
}
}
async function copyToken() {
const token = el.agentEnrollmentToken.textContent;
@@ -399,6 +469,13 @@
el.agentCreateForm.addEventListener("submit", createEnrollment);
}
if (el.agentEnrollmentDownload) {
el.agentEnrollmentDownload.addEventListener(
"click",
downloadWindowsPackage
);
}
if (el.agentEnrollmentCopy) {
el.agentEnrollmentCopy.addEventListener("click", copyToken);
}
+2
View File
@@ -785,6 +785,8 @@ def required_api_permission(method: str, path: str) -> str | None:
return "agents.view" if method == "GET" else "agents.manage"
if path == "/api/agent-enrollments":
return "agents.view" if method == "GET" else "agents.manage"
if path == "/api/agent-enrollments/windows-package":
return "agents.manage"
if repair_parts_request(path):
return "repairs.read" if method == "GET" else "repairs.parts.manage"
if path.startswith("/api/admin/"):