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

+8 -5
View File
@@ -5,15 +5,18 @@ WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt /app/requirements.txt
COPY backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r /app/requirements.txt
COPY app /app/app
COPY alembic.ini /app/alembic.ini
COPY alembic /app/alembic
COPY backend/app /app/app
COPY backend/alembic.ini /app/alembic.ini
COPY backend/alembic /app/alembic
# Canonical Agent source used to construct short-lived enrollment packages.
COPY agents/windows /app/agents/windows
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+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/"):
@@ -136,6 +136,40 @@ class AgentManagementFrontendTests(unittest.TestCase):
css,
)
def test_windows_package_download_is_ephemeral(self):
self.authenticate(("agents.view", "agents.manage"))
page = self.client.get("/agent/management").text
self.assertIn('id="agentEnrollmentDownload"', page)
self.assertIn("Download Windows package", page)
source = (
STATIC_ROOT / "agent-management.js"
).read_text(encoding="utf-8")
self.assertIn(
'"/api/agent-enrollments/windows-package"',
source,
)
self.assertIn(
'link.download = "MinerOpsAgent-Windows.zip"',
source,
)
self.assertIn(
"window.URL.revokeObjectURL(downloadUrl)",
source,
)
self.assertIn("dismissToken();", source)
for forbidden in (
"localStorage",
"sessionStorage",
"agent_secret",
"api3_password",
):
self.assertNotIn(forbidden, source)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,633 @@
"""Runtime PostgreSQL isolation tests for Agent-owned machine transport."""
import asyncio
import json
import os
import unittest
from uuid import UUID
import psycopg
from fastapi import HTTPException
from starlette.requests import Request
from app.agent_zones.repository import AgentZoneRepository
from app.agent_zones.service import AgentZoneService
from app.config import settings
from app.main import (
minerops_agent_api3_enrichment,
minerops_agent_miner_enrichment_v2,
minerops_agent_miner_enrichment_v3,
)
SITE_A = UUID("00000000-0000-4000-8000-000000001710")
SITE_B = UUID("00000000-0000-4000-8000-000000001711")
ZONE_A = UUID("00000000-0000-4000-8000-000000001712")
ZONE_B = UUID("00000000-0000-4000-8000-000000001713")
MINER_A = UUID("00000000-0000-4000-8000-000000001714")
MINER_B = UUID("00000000-0000-4000-8000-000000001715")
AGENT_A = "agent-transport-isolation-a"
AGENT_B = "agent-transport-isolation-b"
IP_A = "198.51.100.171"
IP_B = "198.51.100.172"
MAC_A = "02:00:00:00:17:A1"
MAC_B = "02:00:00:00:17:B1"
def connect():
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
def json_request(path, payload):
body = json.dumps(payload).encode("utf-8")
delivered = False
async def receive():
nonlocal delivered
if delivered:
return {
"type": "http.request",
"body": b"",
"more_body": False,
}
delivered = True
return {
"type": "http.request",
"body": body,
"more_body": False,
}
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": path,
"raw_path": path.encode("ascii"),
"query_string": b"",
"headers": [(b"content-type", b"application/json")],
"client": ("testclient", 12345),
"server": ("testserver", 80),
}
return Request(scope, receive)
class AgentTransportIsolationPostgresTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if os.environ.get(
"MINEROPS_TRANSPORT_ISOLATION_DISPOSABLE"
) != "1":
raise unittest.SkipTest(
"Runtime Agent transport isolation is executed "
"by its dedicated disposable PostgreSQL runner."
)
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO sites (id, name)
VALUES
(%s, 'Transport Isolation Site A'),
(%s, 'Transport Isolation Site B')
ON CONFLICT (id) DO NOTHING
""",
(SITE_A, SITE_B),
)
cur.execute(
"""
INSERT INTO agent_zones (
id,
site_id,
name,
is_enabled
)
VALUES
(
%s,
%s,
'Transport Isolation Zone A',
true
),
(
%s,
%s,
'Transport Isolation Zone B',
true
)
ON CONFLICT (id) DO UPDATE
SET is_enabled = true
""",
(ZONE_A, SITE_A, ZONE_B, SITE_B),
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id,
site_id,
agent_zone_id,
site_name,
last_payload
)
VALUES
(
%s,
%s,
%s,
'Transport Isolation Site A',
'{}'::jsonb
),
(
%s,
%s,
%s,
'Transport Isolation Site B',
'{}'::jsonb
)
ON CONFLICT (agent_id) DO UPDATE
SET
site_id = EXCLUDED.site_id,
agent_zone_id = EXCLUDED.agent_zone_id,
site_name = EXCLUDED.site_name,
last_payload = EXCLUDED.last_payload
""",
(
AGENT_A,
SITE_A,
ZONE_A,
AGENT_B,
SITE_B,
ZONE_B,
),
)
connection.commit()
finally:
connection.close()
@classmethod
def tearDownClass(cls):
# Immutable identity/history rows intentionally are not deleted.
# The disposable Compose runner destroys the database volume.
pass
@staticmethod
def _delete_optional_rows(cur, table, predicate, params):
cur.execute(
"SELECT to_regclass(%s)",
(f"public.{table}",),
)
if cur.fetchone()[0] is not None:
cur.execute(
f"DELETE FROM {table} WHERE {predicate}",
params,
)
def setUp(self):
self.zone_service = AgentZoneService(
AgentZoneRepository(connect)
)
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO miners (
id,
site_id,
agent_zone_id,
asset_tag,
current_mac,
current_ip,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform,
status,
notes,
api3_supported
)
VALUES
(
%s,
%s,
%s,
'TRANSPORT-ZONE-A',
%s,
%s::inet,
'zone-a-before',
'ZONE-A-SERIAL',
'Zone A Model',
'Zone A Model',
'1.0-a',
'fw-a',
'platform-a',
'online',
'Zone A baseline',
false
),
(
%s,
%s,
%s,
'TRANSPORT-ZONE-B',
%s,
%s::inet,
'zone-b-before',
'ZONE-B-SERIAL',
'Zone B Model',
'Zone B Model',
'1.0-b',
'fw-b',
'platform-b',
'online',
'Zone B baseline',
false
)
ON CONFLICT (id) DO NOTHING
""",
(
MINER_A,
SITE_A,
ZONE_A,
MAC_A,
IP_A,
MINER_B,
SITE_B,
ZONE_B,
MAC_B,
IP_B,
),
)
connection.commit()
finally:
connection.close()
def _table_exists(self, cur, table):
cur.execute(
"SELECT to_regclass(%s)",
(f"public.{table}",),
)
return cur.fetchone()[0] is not None
def _snapshot_rows(self, cur, table):
cur.execute(
f"""
SELECT to_jsonb(t)
FROM {table} AS t
WHERE miner_id IN (%s, %s)
ORDER BY to_jsonb(t)::text
""",
(MINER_A, MINER_B),
)
return [row[0] for row in cur.fetchall()]
def _count_optional_miner_rows(self, cur, table, miner_id):
if not self._table_exists(cur, table):
return 0
cur.execute(
f"""
SELECT COUNT(*)
FROM {table}
WHERE miner_id = %s
""",
(miner_id,),
)
return cur.fetchone()[0]
def _capture_inventory_state(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
SELECT id::text, to_jsonb(m)
FROM miners AS m
WHERE id IN (%s, %s)
ORDER BY id
""",
(MINER_A, MINER_B),
)
miners = {
row[0]: row[1]
for row in cur.fetchall()
}
ownerships = self._snapshot_rows(
cur,
"miner_mac_ownerships",
)
identities = self._snapshot_rows(
cur,
"miner_network_identities",
)
counts = {}
for table in (
"miner_status_snapshots",
"miner_api3_enrichment_snapshots",
"miner_api3_hashboard_snapshots",
"miner_pool_snapshots",
):
counts[(table, "A")] = (
self._count_optional_miner_rows(
cur,
table,
MINER_A,
)
)
counts[(table, "B")] = (
self._count_optional_miner_rows(
cur,
table,
MINER_B,
)
)
return {
"miners": miners,
"mac_ownerships": ownerships,
"network_identities": identities,
"snapshot_counts": counts,
}
finally:
connection.close()
def _cross_zone_api3_probe_count(self):
connection = connect()
try:
with connection.cursor() as cur:
if not self._table_exists(
cur,
"miner_api3_enrichment_snapshots",
):
return 0
cur.execute(
"""
SELECT COUNT(*)
FROM miner_api3_enrichment_snapshots
WHERE agent_id = %s
AND (
host(ip_address) = %s
OR upper(mac_address) = upper(%s)
)
""",
(AGENT_A, IP_B, MAC_B),
)
return cur.fetchone()[0]
finally:
connection.close()
def test_v2_cannot_address_other_zone_miner_directly(self):
before = self._capture_inventory_state()
request = json_request(
"/api/agent/miner-enrichment-v2",
{
"miner_id": str(MINER_B),
"mac_address": MAC_B,
"hostname": "zone-a-must-not-write-this",
"model": "cross-zone-v2-probe",
},
)
with self.assertRaises(HTTPException) as context:
asyncio.run(
minerops_agent_miner_enrichment_v2(
request,
AGENT_A,
self.zone_service,
)
)
self.assertEqual(context.exception.status_code, 404)
after = self._capture_inventory_state()
self.assertEqual(after, before)
def test_v2_cross_zone_mac_merge_cannot_mutate_either_miner(self):
before = self._capture_inventory_state()
request = json_request(
"/api/agent/miner-enrichment-v2",
{
"miner_id": str(MINER_A),
"ip_address": IP_A,
"mac_address": MAC_B,
"hostname": "cross-zone-merge-probe",
"miner_sn": "MUST-NOT-CROSS-ZONES",
"model": "Cross Zone Merge Probe",
},
)
error = None
try:
asyncio.run(
minerops_agent_miner_enrichment_v2(
request,
AGENT_A,
self.zone_service,
)
)
except Exception as exc:
error = exc
self.assertIsNotNone(
error,
"A Zone A miner must not successfully claim a Zone B MAC",
)
after = self._capture_inventory_state()
self.assertEqual(after, before)
def test_v3_cannot_match_or_mutate_other_zone_miner(self):
before = self._capture_inventory_state()
request = json_request(
"/api/agent/miner-enrichment-v3",
{
"miner_id": str(MINER_B),
"ip_address": IP_B,
"mac_address": MAC_B,
"hostname": "zone-a-v3-cross-zone-probe",
"miner_sn": "V3-MUST-NOT-MUTATE",
"model": "V3 Cross Zone Probe",
"api_version": "99.99",
"firmware_version": "cross-zone",
"platform": "cross-zone",
"raw_version": {"cross_zone": True},
"raw_miner_info": {"cross_zone": True},
},
)
result = asyncio.run(
minerops_agent_miner_enrichment_v3(
request,
AGENT_A,
self.zone_service,
)
)
self.assertFalse(result["matched"])
after = self._capture_inventory_state()
self.assertEqual(after, before)
def test_api3_ingestion_cannot_associate_or_mutate_other_zone_miner(self):
before = self._capture_inventory_state()
self.assertEqual(
self._cross_zone_api3_probe_count(),
0,
)
request = json_request(
"/api/agent/api3-enrichment",
{
"agent_id": AGENT_B,
"site_name": "spoofed-zone-b",
"items": [
{
"ip": IP_B,
"mac": MAC_B,
"api3_supported": True,
"api3_error": None,
"device_info": {
"msg": {
"network": {
"ip": IP_B,
"mac": MAC_B,
},
"miner": {
"type": "Cross Zone API3 Probe",
"miner-sn": "API3-MUST-NOT-MUTATE",
},
"system": {
"api": "99.99",
"apiswitch": "on",
"fwversion": "cross-zone-fw",
"platform": "cross-zone-platform",
},
"power": {
"sn": "cross-zone-power",
"model": "cross-zone-psu",
},
}
},
"miner_setting": {
"msg": {
"power-mode": "high",
}
},
"miner_status": {
"msg": {
"summary": {
"hash-average": 999,
"power-realtime": 9999,
"power-rate": 99,
"chip-temp-avg": 99,
"chip-temp-max": 100,
},
"pools": [
{
"id": 0,
"status": "Alive",
"account": "cross-zone-worker",
"url": "stratum+tcp://invalid.example:3333",
}
],
"edevs": [
{
"slot": 0,
"hash-average": 999,
"factory-hash": 999,
"freq": 999,
"effective-chips": 999,
}
],
}
},
"fan_setting": {
"msg": {
"mode": "cross-zone",
}
},
"custom_data": {
"msg": {
"CustomerSn": "CROSS-ZONE-CUSTOMER",
}
},
}
],
},
)
asyncio.run(
minerops_agent_api3_enrichment(
request,
AGENT_A,
self.zone_service,
)
)
after = self._capture_inventory_state()
self.assertEqual(after, before)
self.assertEqual(
self._cross_zone_api3_probe_count(),
0,
(
"Agent A must not persist API3 enrichment associated "
"with Zone B identity data"
),
)
if __name__ == "__main__":
unittest.main()
+198
View File
@@ -0,0 +1,198 @@
"""Tests for downloadable Windows Agent enrollment packages."""
import io
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.agent_enrollment.router import router
from app.agent_packages.windows import (
AgentWindowsPackageError,
AgentWindowsPackageSourceError,
WINDOWS_PACKAGE_FILES,
WINDOWS_PACKAGE_ROOT,
build_windows_agent_package,
normalize_hub_url,
)
from app.identity import get_current_user, required_api_permission
TOKEN = "t" * 43
class WindowsPackageBuilderTests(unittest.TestCase):
def source_tree(self, root: Path):
for name in WINDOWS_PACKAGE_FILES:
(root / name).write_text(f"source:{name}\n")
def test_package_contains_only_generic_source_plus_bootstrap_material(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",
source_root=root,
)
with zipfile.ZipFile(io.BytesIO(package)) as archive:
names = set(archive.namelist())
expected = {
f"{WINDOWS_PACKAGE_ROOT}/{name}"
for name in WINDOWS_PACKAGE_FILES
}
expected.update(
{
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json",
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt",
}
)
self.assertEqual(names, expected)
enrollment = json.loads(
archive.read(
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
)
)
self.assertEqual(enrollment["token"], TOKEN)
self.assertEqual(
enrollment["hub_url"],
"https://minerops.example.test",
)
serialized = json.dumps(enrollment)
self.assertNotIn("agent_secret", serialized)
self.assertNotIn("api3_password", serialized)
self.assertNotIn("api3_account", serialized)
self.assertNotIn("agent-config.json", "\n".join(names))
def test_missing_canonical_source_fails_closed(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.source_tree(root)
(root / WINDOWS_PACKAGE_FILES[0]).unlink()
with self.assertRaises(AgentWindowsPackageSourceError):
build_windows_agent_package(
enrollment_token=TOKEN,
hub_url="https://minerops.example.test",
source_root=root,
)
def test_hub_url_is_origin_only(self):
self.assertEqual(
normalize_hub_url("https://minerops.example.test/"),
"https://minerops.example.test",
)
for invalid in (
"ftp://minerops.example.test",
"https://user:pass@minerops.example.test",
"https://minerops.example.test/path",
"https://minerops.example.test/?token=x",
):
with self.subTest(value=invalid):
with self.assertRaises(AgentWindowsPackageError):
normalize_hub_url(invalid)
class WindowsPackageRouteTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(router)
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": "00000000-0000-4000-8000-000000009100",
"username": "package-test",
"display_name": "Package Test",
"role": "Test",
"permissions": tuple(permissions),
}
def test_permission_mapping_is_manage_only(self):
self.assertEqual(
required_api_permission(
"POST",
"/api/agent-enrollments/windows-package",
),
"agents.manage",
)
def test_view_only_user_cannot_download_package(self):
self.authenticate(("agents.view",))
response = self.client.post(
"/api/agent-enrollments/windows-package",
headers={"Origin": "http://testserver"},
json={
"token": TOKEN,
},
)
self.assertEqual(response.status_code, 403)
def test_manager_download_is_same_origin_and_not_cacheable(self):
self.authenticate(("agents.view", "agents.manage"))
response = self.client.post(
"/api/agent-enrollments/windows-package",
headers={"Origin": "http://testserver"},
json={
"token": TOKEN,
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers["content-type"],
"application/zip",
)
self.assertEqual(
response.headers["cache-control"],
"no-store",
)
self.assertIn(
'filename="MinerOpsAgent-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"
)
)
self.assertEqual(enrollment["token"], TOKEN)
def test_package_requires_browser_origin(self):
self.authenticate(("agents.view", "agents.manage"))
response = self.client.post(
"/api/agent-enrollments/windows-package",
json={
"token": TOKEN,
},
)
self.assertEqual(response.status_code, 400)
if __name__ == "__main__":
unittest.main()