Install an agent on a one-off PC from a single command
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

An admin generates a short-lived token in Settings > Endpoints > Endpoints and
copies the command it produces. A tech runs that one line in an elevated
PowerShell on a PC with no agent:

    powershell -ExecutionPolicy Bypass -c "irm https://<server>/shopdb/api/
    endpoints/siteagent/bootstrap.ps1?t=<token> | iex"

The script refuses to run unelevated, refuses a PC that already has the agent
(a managed bay gets it from GE-Enforce), downloads the package the existing
builder produces, checks both binaries against the hashes the server baked in,
installs with NO shared key, and reports what happened. The PC then asks to
enroll and waits for an admin to approve it, which the script watches for
locally for two minutes before exiting.

- New endpointbootstraptokens table and migration
  endpoints0024bootstraptokens: hash only, expiry, use count, revoke.
- Admin routes to generate, list and revoke tokens; the secret is shown once.
- Two token-only routes: the script, and the package (which spends a use).
  Anything wrong with the token answers 404 rather than confirming it exists,
  both are rate limited per address and both are audited with the caller.
- The package builder is now shared rather than duplicated, and a bootstrap
  package can never carry a collector key.
This commit is contained in:
cproudlock
2026-09-15 19:33:00 -04:00
parent 408cf08628
commit ca57b7f728
14 changed files with 1718 additions and 31 deletions

View File

@@ -29,6 +29,7 @@ lowercase hex SHA-256 of the leaf SubjectPublicKeyInfo), optionally with a
second "next" pin for a planned certificate rotation.
"""
import hashlib
import io
import json
import os
@@ -873,6 +874,109 @@ else {{
)
def build_package_zip(baseurl, collectorkey, cadenceseconds, appsettings,
certfilename=None, cert_pem=None, certpath=None,
serverpins=None, includesharedkey=True,
trustcertinroot=True):
"""Assemble the agent package ZIP in memory.
The single place the archive is laid out, so the admin download route and
the token-authenticated bootstrap download cannot drift apart in what they
ship. Callers own validation, pin resolution and the appsettings they pass.
"""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('appsettings.json',
json.dumps(appsettings, indent=2) + '\n')
archive.writestr('install.ps1',
_install_script(baseurl.rstrip('/'), collectorkey,
cadenceseconds, certfilename,
serverpins=serverpins,
includesharedkey=includesharedkey,
trustcertinroot=trustcertinroot))
archive.writestr('README.txt', _readme(includesharedkey))
if os.path.isfile(UNINSTALLER_PATH):
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
agent = agentstore.current_path('agent')
if agent and os.path.isfile(agent):
archive.write(agent, 'shopdbagent.exe')
tray = agentstore.current_path('tray')
if tray and os.path.isfile(tray):
archive.write(tray, 'shopdb-endpoint-tray.exe')
if cert_pem:
archive.writestr('server.crt', cert_pem)
elif certpath:
archive.write(certpath, 'server.crt')
buffer.seek(0)
return buffer
def package_download_name():
"""Versioned download filename for the package, or the plain one."""
agentversion = agentstore.current_version('agent')
return ('shopdb-endpoint-agent-{0}.zip'.format(agentversion)
if agentversion else 'shopdb-endpoint-agent.zip')
# Filenames the package ships the staged binaries under, keyed by store binkey.
STAGED_EXE_NAMES = {
'agent': 'shopdbagent.exe',
'tray': 'shopdb-endpoint-tray.exe',
}
def staged_binary_hashes():
"""{binkey: lowercase sha256} for every staged binary, '' when none.
The bootstrap script bakes these in and re-checks the downloaded ZIP
against them on the endpoint, so a package swapped in transit is refused
before anything is executed. Hashed from the file rather than read out of
the store manifest so a legacy flat-file staging is covered too.
"""
hashes = {}
for binkey in STAGED_EXE_NAMES:
path = agentstore.current_path(binkey)
digest = ''
if path and os.path.isfile(path):
hasher = hashlib.sha256()
with open(path, 'rb') as handle:
for chunk in iter(lambda: handle.read(65536), b''):
hasher.update(chunk)
digest = hasher.hexdigest()
hashes[binkey] = digest
return hashes
def build_keyless_package(baseurl, cadenceseconds=900):
"""The package the bootstrap installer downloads: never a collector key.
Pins this server exactly as the admin builder does with no upload - the
server's own leaf certificate - and bundles that certificate when the app
can read it, which is the self-signed topology where the endpoint would
otherwise fail its own TLS validation.
"""
appsettings = dict(_BASE_APPSETTINGS)
appsettings['cadenceseconds'] = cadenceseconds
appsettings['apikey'] = ''
certpath = _server_cert_path()
certfilename = 'server.crt' if certpath else None
currentpin, pinisca = _current_server_facts()
serverpins = [pin for pin in (currentpin,) if pin]
appsettings['serverpins'] = serverpins
buffer = build_package_zip(
baseurl, '', cadenceseconds, appsettings,
certfilename=certfilename, certpath=certpath,
serverpins=serverpins, includesharedkey=False,
trustcertinroot=not pinisca)
warning = CA_CERT_BUILD_WARNING if (currentpin and pinisca) else None
return buffer, warning
def _siteagent_status_payload():
"""Whether a prebuilt agent binary is staged for packaging.
@@ -1035,41 +1139,18 @@ def package_siteagent():
serverpins = list(dict.fromkeys(serverpins))
appsettings['serverpins'] = serverpins
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('appsettings.json',
json.dumps(appsettings, indent=2) + '\n')
archive.writestr('install.ps1',
_install_script(baseurl.rstrip('/'), collectorkey,
cadenceseconds, certfilename,
serverpins=serverpins,
includesharedkey=includesharedkey,
trustcertinroot=trustcertinroot))
archive.writestr('README.txt', _readme(includesharedkey))
buffer = build_package_zip(
baseurl, collectorkey, cadenceseconds, appsettings,
certfilename=certfilename, cert_pem=cert_pem, certpath=certpath,
serverpins=serverpins, includesharedkey=includesharedkey,
trustcertinroot=trustcertinroot)
if os.path.isfile(UNINSTALLER_PATH):
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
agent = agentstore.current_path('agent')
if agent and os.path.isfile(agent):
archive.write(agent, 'shopdbagent.exe')
tray = agentstore.current_path('tray')
if tray and os.path.isfile(tray):
archive.write(tray, 'shopdb-endpoint-tray.exe')
if cert_pem:
archive.writestr('server.crt', cert_pem)
elif certpath:
archive.write(certpath, 'server.crt')
buffer.seek(0)
# Stamp the staged agent version into the download filename so a DT operator
# can see which agent build a package carries at a glance. This is cosmetic:
# install.ps1 finds the exe by its fixed name inside the ZIP, so the download
# name never affects the install. Falls back to the unversioned name when no
# managed version is current (only a legacy flat file is staged).
agentversion = agentstore.current_version('agent')
download_name = ('shopdb-endpoint-agent-{0}.zip'.format(agentversion)
if agentversion else 'shopdb-endpoint-agent.zip')
download_name = package_download_name()
response = send_file(
buffer,
mimetype='application/zip',