Add agentless adoption tool for endpoints
Standalone one-shot enrollment tool that creates a ShopDB PC + Endpoint entry using only the endpoints collector key, reusing the existing collector adopt path. Collects identity, WMI hardware, and installed-software SBOM; requires admin. Server-side packaging route attaches to the existing endpoints blueprint; no core contract change.
This commit is contained in:
committed by
cproudlock
parent
cfb0b9ca4e
commit
7040832565
77
plugins/endpoints/ADOPTION-TOOL-SPIKE.md
Normal file
77
plugins/endpoints/ADOPTION-TOOL-SPIKE.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Adoption tool spike
|
||||
|
||||
Status: spike. Built on the `origin/main` baseline so it can be cherry-picked as
|
||||
a single unit once endpoint agent development is complete.
|
||||
|
||||
## What it is
|
||||
|
||||
A one-shot, agentless enrollment tool. An admin generates a small download from
|
||||
the Endpoints key settings page; running the tool once on a PC creates a ShopDB
|
||||
PC (Computer) and Endpoint entry using only the endpoints collector key. It is
|
||||
NOT the endpoint agent: it installs nothing, runs no service, and sends no
|
||||
ongoing telemetry. It targets sites that want an inventory entry without running
|
||||
the full agent.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Admin generates or reuses an endpoint agent key (existing `/agentkey` flow).
|
||||
2. Admin opens the "Adoption tool" section, confirms the base URL (prefilled
|
||||
from the browser origin) and pastes the collector key (prefilled when a key
|
||||
was just generated, since the secret is only shown once).
|
||||
3. `POST /api/endpoints/adoptiontool/package` (admin-gated) returns a ZIP with
|
||||
`adopt.config.json`, `README.txt`, and `shopdb-adopt.exe` when a prebuilt
|
||||
binary is staged on the server.
|
||||
4. On the target PC, `shopdb-adopt.exe` reads the config beside it (or CLI args),
|
||||
then POSTs to `/api/collector/endpoints` with `X-API-Key` and `adopt=true`.
|
||||
The existing `_adopt_computer` path creates the Computer + EndpointState.
|
||||
|
||||
## Collection
|
||||
|
||||
The tool gathers the same identity, hardware, and SBOM subset the endpoint
|
||||
agent collects, using identical WMI queries and wire keys so the server stores
|
||||
it without translation:
|
||||
|
||||
- Identity: `hostname`, `loggedinuser`, `ipaddress`.
|
||||
- Hardware: `serialnumber` (Win32_BIOS), `vendorname` / `pctype` / `modelnumber`
|
||||
(Win32_ComputerSystem), `osname` and `lastboottime` (Win32_OperatingSystem).
|
||||
- SBOM: `applications` = both Uninstall registry hives (64-bit + WOW6432Node),
|
||||
de-duped `{displayname, displayversion, publisher}`. The server logs this as
|
||||
an unpersisted key today (same as the agent) until the SBOM store lands.
|
||||
|
||||
The tool requires administrator rights (enforced by its app manifest
|
||||
`requireAdministrator`, with a runtime elevation guard) because the machine
|
||||
Uninstall hives and full WMI need elevation, or collection would be silently
|
||||
partial.
|
||||
|
||||
## Cherry-pick boundary
|
||||
|
||||
The spike is deliberately isolated. Cherry-pick exactly:
|
||||
|
||||
- `agent/ShopDbAdoptionTool/` (new standalone tool project, incl. `app.manifest`)
|
||||
- `agent/ShopDbEndpointAgent.slnx` (one line adding the project)
|
||||
- `plugins/endpoints/api/adoption.py` (new module; attaches to `endpoints_bp`)
|
||||
- `plugins/endpoints/api/__init__.py` (one-line import to register the route)
|
||||
- `plugins/endpoints/frontend/views/AgentKeySettings.vue` (new UI section only)
|
||||
- `plugins/endpoints/ADOPTION-TOOL-SPIKE.md` (this file)
|
||||
|
||||
`plugins/endpoints/api/routes.py` is intentionally untouched so the delivery
|
||||
spike changes there do not tangle with this feature.
|
||||
|
||||
## Prebuilt exe distribution
|
||||
|
||||
The api container has no .NET SDK, so the package embeds the exe only when a
|
||||
prebuilt copy is staged at `instance/adoptiontool/shopdb-adopt.exe`. Otherwise
|
||||
the ZIP ships the config plus README (build the exe out of band, same model as
|
||||
the endpoint agent installer bundle). Follow-up: a CI job that publishes
|
||||
`shopdb-adopt.exe` and stages it into the image or instance path.
|
||||
|
||||
## Known follow-ups
|
||||
|
||||
- Agentless health signal: an adopted PC never reports again, so
|
||||
`EndpointState.lastreportedat` reflects only the adoption moment. The fleet
|
||||
health/dashboard logic should distinguish "agentless, adopted" from
|
||||
"agent expected but silent" so these PCs do not read as permanently failed.
|
||||
Left out of this spike to keep the server create-path unchanged for a clean
|
||||
cherry-pick; the tool does not send an `agentless` marker yet because the
|
||||
server does not persist one (`KNOWN_PAYLOAD_KEYS`).
|
||||
- CI packaging of the prebuilt exe (see above).
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Endpoints plugin API."""
|
||||
|
||||
from .routes import endpoints_bp
|
||||
# Attaches the adoption-tool packaging route to endpoints_bp (spike).
|
||||
from . import adoption # noqa: F401
|
||||
|
||||
__all__ = ['endpoints_bp']
|
||||
|
||||
107
plugins/endpoints/api/adoption.py
Normal file
107
plugins/endpoints/api/adoption.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Endpoints adoption-tool packaging (SPIKE).
|
||||
|
||||
This module is deliberately self-contained so it can be cherry-picked onto
|
||||
main as a single unit. It attaches ONE admin-gated route to the existing
|
||||
endpoints blueprint and never edits routes.py.
|
||||
|
||||
The adoption tool is NOT the endpoint agent. It is a one-shot, agentless
|
||||
enrollment client: given only the endpoints collector key and this server's
|
||||
base URL, it creates a ShopDB PC (Computer) + Endpoint entry via the existing
|
||||
POST /api/collector/endpoints adopt path, then exits. This route packages that
|
||||
tool for download: a ZIP carrying the operator-supplied config, a README, and
|
||||
(when a prebuilt binary is staged on the server) the shopdb-adopt.exe.
|
||||
|
||||
The collector key is supplied by the admin and is NOT stored or logged here.
|
||||
The minted secret is unretrievable after generation, so the operator pastes it
|
||||
into the generator; it only lives inside the downloaded config file.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
from flask import request, current_app, send_file
|
||||
|
||||
from shopdb.api import error_response, ErrorCodes, require_role
|
||||
|
||||
from .routes import endpoints_bp
|
||||
|
||||
# A staged, prebuilt shopdb-adopt.exe is embedded when present. The container
|
||||
# has no .NET SDK, so this mirrors the endpoint agent installer model: build the
|
||||
# exe out of band, drop it under instance/adoptiontool/, and it ships in the ZIP.
|
||||
STAGED_EXE_RELPATH = os.path.join('adoptiontool', 'shopdb-adopt.exe')
|
||||
|
||||
_README = """ShopDB adoption tool (spike)
|
||||
|
||||
WHAT THIS IS
|
||||
A one-shot, agentless enrollment tool. Run it once on a PC and it creates a
|
||||
ShopDB PC and Endpoint entry using only the collector key in the config file
|
||||
next to this exe. It collects identity, hardware, and installed-software (SBOM)
|
||||
details, then exits. It installs nothing, runs no service, and sends no
|
||||
ongoing telemetry. It is NOT the endpoint agent.
|
||||
|
||||
FILES
|
||||
shopdb-adopt.exe The tool (present only if the server had a prebuilt copy).
|
||||
adopt.config.json The base URL and collector key this tool will use.
|
||||
|
||||
USAGE
|
||||
1. Keep shopdb-adopt.exe and adopt.config.json in the same folder.
|
||||
2. Right-click shopdb-adopt.exe and choose Run as administrator. Elevation is
|
||||
required so hardware and installed-software collection succeeds.
|
||||
3. It reports the machine to ShopDB and exits 0 on success.
|
||||
|
||||
You can override config values with arguments:
|
||||
shopdb-adopt.exe --baseurl https://shopdb.example --collectorkey KEY
|
||||
|
||||
SECURITY
|
||||
adopt.config.json contains a live collector key. Treat it as a secret. The
|
||||
key is not recoverable from the ShopDB server after it was generated, so keep
|
||||
this download safe or regenerate the key to revoke it.
|
||||
"""
|
||||
|
||||
|
||||
def _build_readme():
|
||||
return _README
|
||||
|
||||
|
||||
@endpoints_bp.route('/adoptiontool/package', methods=['POST'])
|
||||
@require_role('admin')
|
||||
def package_adoption_tool():
|
||||
"""Bundle the adoption tool as a downloadable ZIP.
|
||||
|
||||
Body: {"baseurl": "...", "collectorkey": "..."}. Returns an in-memory ZIP
|
||||
with adopt.config.json, README.txt, and the staged exe when available. The
|
||||
collector key is embedded in the config only; it is never stored or logged.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
baseurl = (body.get('baseurl') or '').strip()
|
||||
collectorkey = (body.get('collectorkey') or '').strip()
|
||||
|
||||
if not baseurl or not collectorkey:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'baseurl and collectorkey are required',
|
||||
http_code=400)
|
||||
|
||||
config = {
|
||||
'baseurl': baseurl.rstrip('/'),
|
||||
'collectorkey': collectorkey,
|
||||
}
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('adopt.config.json',
|
||||
json.dumps(config, indent=2) + '\n')
|
||||
archive.writestr('README.txt', _build_readme())
|
||||
|
||||
staged = os.path.join(current_app.instance_path, STAGED_EXE_RELPATH)
|
||||
if os.path.isfile(staged):
|
||||
archive.write(staged, 'shopdb-adopt.exe')
|
||||
|
||||
buffer.seek(0)
|
||||
return send_file(
|
||||
buffer,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name='shopdb-adoption-tool.zip')
|
||||
@@ -138,6 +138,57 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================================
|
||||
Adoption tool (spike)
|
||||
================================================================= -->
|
||||
<section class="settings-section">
|
||||
<h3>Adoption tool</h3>
|
||||
<div class="card form-card">
|
||||
<p class="field-hint">
|
||||
Generate a one-shot, agentless enrollment tool. Run it once on a PC and
|
||||
it creates a ShopDB PC and Endpoint entry using only the collector key -
|
||||
no service, no polling, no ongoing telemetry. Use this for machines you
|
||||
want inventoried without running the full endpoint agent.
|
||||
</p>
|
||||
<p class="field-hint">
|
||||
Paste a collector key below (generate one above first; the secret is
|
||||
only shown once). The key is embedded in the downloaded config and is
|
||||
not stored on the server.
|
||||
</p>
|
||||
|
||||
<label class="field-label" for="adopt-baseurl">Server base URL</label>
|
||||
<input
|
||||
id="adopt-baseurl"
|
||||
v-model="adoptbaseurl"
|
||||
class="form-control"
|
||||
type="text"
|
||||
placeholder="https://shopdb.example"
|
||||
/>
|
||||
|
||||
<label class="field-label" for="adopt-key">Collector key</label>
|
||||
<input
|
||||
id="adopt-key"
|
||||
v-model="adoptcollectorkey"
|
||||
class="form-control mono"
|
||||
type="text"
|
||||
placeholder="Paste the endpoint agent key"
|
||||
/>
|
||||
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
:disabled="adoptbusy || !adoptbaseurl || !adoptcollectorkey"
|
||||
@click="downloadAdoptionTool"
|
||||
>
|
||||
{{ adoptbusy ? 'Building...' : 'Download adoption tool' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="adoptmessage" class="field-hint">{{ adoptmessage }}</p>
|
||||
<p v-if="adopterror" class="field-error">{{ adopterror }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================================
|
||||
Build site agent (placeholder)
|
||||
================================================================= -->
|
||||
@@ -197,6 +248,13 @@ const copied = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
// Adoption tool (spike): build a one-shot, agentless enrollment download.
|
||||
const adoptbaseurl = ref('')
|
||||
const adoptcollectorkey = ref('')
|
||||
const adoptbusy = ref(false)
|
||||
const adoptmessage = ref('')
|
||||
const adopterror = ref('')
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return ''
|
||||
const parsed = new Date(value)
|
||||
@@ -209,6 +267,7 @@ function applyStatus(data) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
adoptbaseurl.value = window.location.origin
|
||||
try {
|
||||
const response = await api.get('/endpoints/agentkey')
|
||||
applyStatus(response.data.data || {})
|
||||
@@ -238,6 +297,9 @@ async function regenerate() {
|
||||
const data = response.data.data || {}
|
||||
applyStatus(data)
|
||||
secret.value = data.secret || ''
|
||||
if (data.secret) {
|
||||
adoptcollectorkey.value = data.secret
|
||||
}
|
||||
message.value = 'Endpoint agent key generated.'
|
||||
} catch (regenError) {
|
||||
error.value = regenError.response?.data?.data?.error?.message
|
||||
@@ -276,6 +338,34 @@ async function copySecret() {
|
||||
console.error(copyError)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAdoptionTool() {
|
||||
adoptbusy.value = true
|
||||
adoptmessage.value = ''
|
||||
adopterror.value = ''
|
||||
try {
|
||||
const response = await api.post(
|
||||
'/endpoints/adoptiontool/package',
|
||||
{ baseurl: adoptbaseurl.value, collectorkey: adoptcollectorkey.value },
|
||||
{ responseType: 'blob' })
|
||||
const url = window.URL.createObjectURL(response.data)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'shopdb-adoption-tool.zip'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
adoptmessage.value = 'Adoption tool downloaded.'
|
||||
} catch (downloadError) {
|
||||
adopterror.value = downloadError.response?.status === 403
|
||||
? 'Only an administrator can build the adoption tool'
|
||||
: 'Could not build the adoption tool'
|
||||
console.error(downloadError)
|
||||
} finally {
|
||||
adoptbusy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user