feat: add self-enrolling Windows Agent package
This commit is contained in:
1 parent
b9b5e5e36f
commit
abf5654a25
20 files changed
+1834
-42
No files matched your search
@@ -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."""
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Downloadable MinerOps Agent deployment packages."""
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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/"):
|
||||
|
||||
Reference in new issue
Block a user