feat(windows): add recoverable existing-agent credential upgrade
This commit is contained in:
1 parent
3cfb0d1abe
commit
97a0139e3d
25 files changed
+11122
-108
No files matched your search
@@ -148,6 +148,7 @@ def download_windows_agent_package(
|
||||
package = build_windows_agent_package(
|
||||
enrollment_token=package_request.token,
|
||||
hub_url=hub_url,
|
||||
workflow="new_agent",
|
||||
)
|
||||
except AgentWindowsPackageSourceError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
@@ -166,6 +167,50 @@ def download_windows_agent_package(
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/agent-credential-transitions/windows-package")
|
||||
def download_windows_agent_credential_transition_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,
|
||||
workflow="existing_agent_credential_transition",
|
||||
)
|
||||
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-Credential-Upgrade-Windows.zip"'
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/api/agent/enroll",
|
||||
response_model=AgentEnrollResponse,
|
||||
|
||||
@@ -14,6 +14,7 @@ WINDOWS_PACKAGE_ROOT = "MinerOpsAgent-Windows"
|
||||
|
||||
WINDOWS_PACKAGE_FILES = (
|
||||
"Enroll-MinerOpsAgent.ps1",
|
||||
"Upgrade-MinerOpsAgentCredential.ps1",
|
||||
"install.ps1",
|
||||
"uninstall.ps1",
|
||||
"MinerOpsAgentService.ps1",
|
||||
@@ -80,6 +81,7 @@ def build_windows_agent_package(
|
||||
*,
|
||||
enrollment_token: str,
|
||||
hub_url: str,
|
||||
workflow: str = "new_agent",
|
||||
source_root: Path | None = None,
|
||||
) -> bytes:
|
||||
token = enrollment_token.strip()
|
||||
@@ -91,6 +93,12 @@ def build_windows_agent_package(
|
||||
|
||||
normalized_hub_url = normalize_hub_url(hub_url)
|
||||
|
||||
if workflow not in {
|
||||
"new_agent",
|
||||
"existing_agent_credential_transition",
|
||||
}:
|
||||
raise AgentWindowsPackageError("Unsupported Windows Agent workflow")
|
||||
|
||||
root = (
|
||||
Path(source_root)
|
||||
if source_root is not None
|
||||
@@ -117,18 +125,27 @@ def build_windows_agent_package(
|
||||
enrollment = {
|
||||
"hub_url": normalized_hub_url,
|
||||
"token": token,
|
||||
"workflow": workflow,
|
||||
}
|
||||
|
||||
instructions = """MinerOps Windows Agent
|
||||
if workflow == "new_agent":
|
||||
command = ".\\Enroll-MinerOpsAgent.ps1"
|
||||
description = "enrollment"
|
||||
else:
|
||||
command = ".\\Upgrade-MinerOpsAgentCredential.ps1"
|
||||
description = "existing-Agent credential transition"
|
||||
|
||||
instructions = f"""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
|
||||
{command}
|
||||
|
||||
The enrollment credential is short-lived and single-use.
|
||||
This package is for the {description} workflow. The credential is short-lived
|
||||
and single-use. Do not use the other bootstrap script with this package.
|
||||
Do not post enrollment.json in chat, tickets, screenshots, or logs.
|
||||
Delete the ZIP and extracted setup folder after successful installation.
|
||||
"""
|
||||
|
||||
@@ -74,7 +74,7 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<p id="agentEnrollmentResultCopy">
|
||||
This token is displayed only for this enrollment step.
|
||||
Keep it private. MinerOps does not store the plaintext token.
|
||||
</p>
|
||||
@@ -152,7 +152,9 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="agentManagementContent" hidden>
|
||||
<div id="agentManagementContent"
|
||||
data-can-manage-agents="{str(can_manage_agents).lower()}"
|
||||
hidden>
|
||||
<section class="agent-summary-grid" aria-label="Agent Management summary">
|
||||
<div class="panel agent-summary-card">
|
||||
<span>Registered Agents</span>
|
||||
@@ -197,6 +199,7 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
||||
<th>Last check-in</th>
|
||||
<th>Credential</th>
|
||||
<th>Routing</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentRows"></tbody>
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"agentCreateSubmit",
|
||||
"agentCreateStatus",
|
||||
"agentEnrollmentResult",
|
||||
"agentEnrollmentResultTitle",
|
||||
"agentEnrollmentResultCopy",
|
||||
"agentEnrollmentToken",
|
||||
"agentEnrollmentExpires",
|
||||
"agentEnrollmentDownload",
|
||||
@@ -48,6 +50,8 @@
|
||||
};
|
||||
|
||||
let enrollments = [];
|
||||
let packageEndpoint = "/api/agent-enrollments/windows-package";
|
||||
let packageFilename = "MinerOpsAgent-Windows.zip";
|
||||
|
||||
function text(value, fallback) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
@@ -143,6 +147,27 @@
|
||||
cell(words(agent.routing_state))
|
||||
);
|
||||
|
||||
const action = document.createElement("td");
|
||||
const canManage =
|
||||
el.agentManagementContent.dataset.canManageAgents === "true";
|
||||
if (
|
||||
canManage &&
|
||||
agent.credential_state === "legacy" &&
|
||||
agent.routing_state === "assigned"
|
||||
) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "quiet-button";
|
||||
button.textContent = "Upgrade credential";
|
||||
button.addEventListener("click", function () {
|
||||
createCredentialTransition(agent);
|
||||
});
|
||||
action.appendChild(button);
|
||||
} else {
|
||||
action.textContent = "—";
|
||||
}
|
||||
row.appendChild(action);
|
||||
|
||||
el.agentRows.appendChild(row);
|
||||
});
|
||||
|
||||
@@ -188,7 +213,10 @@
|
||||
cell(words(enrollment.status)),
|
||||
cell(dateTime(enrollment.created_at)),
|
||||
cell(dateTime(enrollment.expires_at)),
|
||||
cell(text(enrollment.enrolled_agent_id, "Not enrolled"))
|
||||
cell(text(
|
||||
enrollment.enrolled_agent_id || enrollment.target_agent_id,
|
||||
"Not enrolled"
|
||||
))
|
||||
);
|
||||
|
||||
el.agentEnrollmentRows.appendChild(row);
|
||||
@@ -338,15 +366,15 @@
|
||||
});
|
||||
|
||||
const enrollment = payload.data;
|
||||
|
||||
el.agentEnrollmentToken.textContent =
|
||||
enrollment.enrollment_token;
|
||||
|
||||
el.agentEnrollmentExpires.textContent =
|
||||
"Expires " + dateTime(enrollment.expires_at);
|
||||
|
||||
el.agentEnrollmentCopyStatus.textContent = "";
|
||||
el.agentEnrollmentResult.hidden = false;
|
||||
showOneTimeToken({
|
||||
token: enrollment.enrollment_token,
|
||||
expiresAt: enrollment.expires_at,
|
||||
title: "Enrollment created",
|
||||
copy: "This token creates a new Agent identity. Keep it private. " +
|
||||
"MinerOps does not store the plaintext token.",
|
||||
endpoint: "/api/agent-enrollments/windows-package",
|
||||
filename: "MinerOpsAgent-Windows.zip"
|
||||
});
|
||||
el.agentCreateStatus.textContent = "Enrollment created.";
|
||||
|
||||
el.agentDisplayName.value = "";
|
||||
@@ -360,6 +388,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showOneTimeToken(options) {
|
||||
el.agentEnrollmentToken.textContent = options.token;
|
||||
el.agentEnrollmentExpires.textContent =
|
||||
"Expires " + dateTime(options.expiresAt);
|
||||
el.agentEnrollmentResultTitle.textContent = options.title;
|
||||
el.agentEnrollmentResultCopy.textContent = options.copy;
|
||||
el.agentEnrollmentCopyStatus.textContent = "";
|
||||
el.agentEnrollmentResult.hidden = false;
|
||||
packageEndpoint = options.endpoint;
|
||||
packageFilename = options.filename;
|
||||
}
|
||||
|
||||
async function createCredentialTransition(agent) {
|
||||
const confirmed = window.confirm(
|
||||
"Create a one-time credential upgrade for " + agent.agent_id +
|
||||
"? Its Agent identity, Site, and Zone will remain unchanged."
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const payload = await api("/api/agent-credential-transitions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({agent_id: agent.agent_id})
|
||||
});
|
||||
const transition = payload.data;
|
||||
showOneTimeToken({
|
||||
token: transition.transition_token,
|
||||
expiresAt: transition.expires_at,
|
||||
title: "Existing Agent credential upgrade created",
|
||||
copy: "This token is bound to the existing Agent identity " +
|
||||
transition.target_agent_id + ". It cannot enroll a new " +
|
||||
"Agent or transfer its Zone.",
|
||||
endpoint: "/api/agent-credential-transitions/windows-package",
|
||||
filename: "MinerOpsAgent-Credential-Upgrade-Windows.zip"
|
||||
});
|
||||
el.agentCreateStatus.textContent =
|
||||
"Credential upgrade created for " + transition.target_agent_id + ".";
|
||||
await refreshData(false);
|
||||
} catch (error) {
|
||||
el.agentCreateStatus.textContent =
|
||||
error.message || "Credential upgrade could not be created.";
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWindowsPackage() {
|
||||
const token = el.agentEnrollmentToken.textContent;
|
||||
|
||||
@@ -375,7 +451,7 @@
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/agent-enrollments/windows-package",
|
||||
packageEndpoint,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
@@ -408,7 +484,7 @@
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = downloadUrl;
|
||||
link.download = "MinerOpsAgent-Windows.zip";
|
||||
link.download = packageFilename;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
@@ -790,6 +790,8 @@ def required_api_permission(method: str, path: str) -> str | None:
|
||||
return "agents.manage"
|
||||
if path == "/api/agent-credential-transitions":
|
||||
return "agents.manage"
|
||||
if path == "/api/agent-credential-transitions/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/"):
|
||||
|
||||
@@ -62,6 +62,7 @@ class AgentManagementFrontendTests(unittest.TestCase):
|
||||
self.assertIn('id="agentSite"', managed)
|
||||
self.assertIn('id="agentZone"', managed)
|
||||
self.assertIn('id="agentDisplayName"', managed)
|
||||
self.assertIn('data-can-manage-agents="true"', managed)
|
||||
self.assertIn("Linux x64 — coming later", managed)
|
||||
self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed)
|
||||
|
||||
@@ -100,6 +101,11 @@ class AgentManagementFrontendTests(unittest.TestCase):
|
||||
'api("/api/agent-enrollments", {',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'api("/api/agent-credential-transitions", {',
|
||||
source,
|
||||
)
|
||||
self.assertIn("Upgrade credential", source)
|
||||
|
||||
for unsafe in (
|
||||
"innerHTML",
|
||||
@@ -153,9 +159,10 @@ class AgentManagementFrontendTests(unittest.TestCase):
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'link.download = "MinerOpsAgent-Windows.zip"',
|
||||
'packageFilename = "MinerOpsAgent-Windows.zip"',
|
||||
source,
|
||||
)
|
||||
self.assertIn("link.download = packageFilename", source)
|
||||
self.assertIn(
|
||||
"window.URL.revokeObjectURL(downloadUrl)",
|
||||
source,
|
||||
|
||||
@@ -65,6 +65,7 @@ class WindowsPackageBuilderTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(enrollment["token"], TOKEN)
|
||||
self.assertEqual(enrollment["workflow"], "new_agent")
|
||||
self.assertEqual(
|
||||
enrollment["hub_url"],
|
||||
"https://minerops.example.test",
|
||||
@@ -77,6 +78,50 @@ class WindowsPackageBuilderTests(unittest.TestCase):
|
||||
self.assertNotIn("api3_account", serialized)
|
||||
self.assertNotIn("agent-config.json", "\n".join(names))
|
||||
|
||||
def test_existing_agent_transition_package_is_generic_and_distinct(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.source_tree(root)
|
||||
package = build_windows_agent_package(
|
||||
enrollment_token=TOKEN,
|
||||
hub_url="https://minerops.example.test",
|
||||
workflow="existing_agent_credential_transition",
|
||||
source_root=root,
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(package)) as archive:
|
||||
enrollment = json.loads(
|
||||
archive.read(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
|
||||
)
|
||||
)
|
||||
instructions = archive.read(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt"
|
||||
).decode("utf-8")
|
||||
names = set(archive.namelist())
|
||||
|
||||
self.assertEqual(
|
||||
enrollment,
|
||||
{
|
||||
"hub_url": "https://minerops.example.test",
|
||||
"token": TOKEN,
|
||||
"workflow": "existing_agent_credential_transition",
|
||||
},
|
||||
)
|
||||
self.assertIn("Upgrade-MinerOpsAgentCredential.ps1", instructions)
|
||||
self.assertIn(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/Upgrade-MinerOpsAgentCredential.ps1",
|
||||
names,
|
||||
)
|
||||
serialized = json.dumps(enrollment)
|
||||
for forbidden in (
|
||||
"plexus-windows-agent",
|
||||
"site_id",
|
||||
"agent_zone_id",
|
||||
"agent_secret",
|
||||
):
|
||||
self.assertNotIn(forbidden, serialized)
|
||||
|
||||
def test_missing_canonical_source_fails_closed(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
@@ -134,6 +179,13 @@ class WindowsPackageRouteTests(unittest.TestCase):
|
||||
),
|
||||
"agents.manage",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_api_permission(
|
||||
"POST",
|
||||
"/api/agent-credential-transitions/windows-package",
|
||||
),
|
||||
"agents.manage",
|
||||
)
|
||||
|
||||
def test_view_only_user_cannot_download_package(self):
|
||||
self.authenticate(("agents.view",))
|
||||
@@ -180,6 +232,35 @@ class WindowsPackageRouteTests(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
self.assertEqual(enrollment["token"], TOKEN)
|
||||
self.assertEqual(enrollment["workflow"], "new_agent")
|
||||
|
||||
def test_manager_downloads_distinct_existing_agent_upgrade_package(self):
|
||||
self.authenticate(("agents.view", "agents.manage"))
|
||||
|
||||
response = self.client.post(
|
||||
"/api/agent-credential-transitions/windows-package",
|
||||
headers={"Origin": "http://testserver"},
|
||||
json={"token": TOKEN},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
self.assertIn(
|
||||
"MinerOpsAgent-Credential-Upgrade-Windows.zip",
|
||||
response.headers["content-disposition"],
|
||||
)
|
||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||
enrollment = json.loads(
|
||||
archive.read(f"{WINDOWS_PACKAGE_ROOT}/enrollment.json")
|
||||
)
|
||||
instructions = archive.read(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt"
|
||||
).decode("utf-8")
|
||||
self.assertEqual(
|
||||
enrollment["workflow"],
|
||||
"existing_agent_credential_transition",
|
||||
)
|
||||
self.assertIn("Upgrade-MinerOpsAgentCredential.ps1", instructions)
|
||||
|
||||
def test_package_requires_browser_origin(self):
|
||||
self.authenticate(("agents.view", "agents.manage"))
|
||||
|
||||
Reference in new issue
Block a user