diff --git a/plugins/endpoints/AGENT-CONTRACT.md b/plugins/endpoints/AGENT-CONTRACT.md index a0ed91b8..7b6edc42 100644 --- a/plugins/endpoints/AGENT-CONTRACT.md +++ b/plugins/endpoints/AGENT-CONTRACT.md @@ -763,3 +763,74 @@ Read needs `endpoints.fleet`; writes need the admin role and are audited. `{"assetids": [...]}`. An explicit id is queued when this is the PC's only blocker and is reported in `retried`; `{"next": n}` never picks such a PC, and a PC with any other blocker as well is still skipped. + +### 9.8 One-line bootstrap install + +For a ONE-OFF PC. A managed bay keeps getting the agent from GE-Enforce; this +is the path for a machine nobody is going to author a manifest entry for. + +An admin mints a short-lived token in Settings > Endpoints > Build site agent > +One-line install and hands a technician one command, which is run in an +ELEVATED PowerShell on a PC with no agent: + +``` +powershell -ExecutionPolicy Bypass -c "irm /api/endpoints/siteagent/bootstrap.ps1?t= | iex" +``` + +The token is only an authorization to FETCH the installer. The package it +serves carries no shared key, whatever `plugin.endpoints.sharedkeyallowed` +says, so the PC enrolls by the normal 9.2 path and waits in Pending until an +admin approves it. + +Admin routes (admin role, audited): + +- `POST /api/endpoints/siteagent/bootstrap/tokens` `{"hours": 24, "maxuses": 25}` + (`hours` 1-168, `maxuses` 1-500). Returns `{"secret", "command", "token"}`. + The secret is in the response exactly once; only its sha256 and a 12-character + `tokenprefix` are stored. +- `GET /api/endpoints/siteagent/bootstrap/tokens` - prefix, created, expires, + `usecount` / `maxuses`, `revokedat`, `lastusedat`, `lastusedip` and a `state` + of `active`, `expired`, `exhausted` or `revoked`. Never a secret. +- `POST /api/endpoints/siteagent/bootstrap/tokens//revoke` + +Token-authenticated routes. No login; the token is the whole authorization and +travels in the query string as `t`, because a PC with no agent has neither a +credential nor a user session to put in a header: + +| Route | Spends a use | Returns | +|---|---|---| +| `GET /api/endpoints/siteagent/bootstrap.ps1?t=...` | no | `text/plain` PowerShell, generated per token | +| `GET /api/endpoints/siteagent/bootstrap/package?t=...` | yes | the keyless agent package ZIP | + +Both answer **404** for a token that is unknown, malformed, expired, revoked or +exhausted, so the URL confirms nothing about which tokens exist. Both are +rate-limited per IP (fixed window, `ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX` in +`ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS`, 429 `RATE_LIMITED` over +budget) and both write an audit row against `EndpointBootstrapToken` carrying +the caller's IP. Fetching the script deliberately does not spend a use: a +retried paste must not burn the download budget. + +The generated script: + +- refuses to run unelevated (exit 1) and says to reopen PowerShell as + administrator; +- refuses when the agent service already exists (exit 2) and says a managed bay + should get the agent from GE-Enforce, unless `SHOPDB_BOOTSTRAP_FORCE` is set + to anything but `0` in the same session; +- downloads the package to a temp directory over TLS with normal chain + validation (nothing relaxes certificate checking), expands it, and verifies + `shopdbagent.exe` and `shopdb-endpoint-tray.exe` against the SHA-256 values + the server baked in at generation time. A mismatch, a missing binary, or a + binary the server never staged refuses the install (exit 3); +- runs the package's `install.ps1` with no key, then deletes the temp + directory; +- prints the installed version and service state, and exits 4 when the service + is not Running; +- prints "This PC is waiting for approval - an admin approves it in Settings > + Endpoints > Enrollment", then watches + `%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin` for up to two + minutes and prints "approved and enrolled" if the credential lands. This is a + LOCAL file watch, not a server poll: there is no public enrollment-status + route and adding one would hand an anonymous caller a way to probe the fleet. + +Exit code 0 on install success even when the PC is still pending. diff --git a/plugins/endpoints/api/__init__.py b/plugins/endpoints/api/__init__.py index f6f64882..e604b08d 100644 --- a/plugins/endpoints/api/__init__.py +++ b/plugins/endpoints/api/__init__.py @@ -11,5 +11,8 @@ from . import agentupload # noqa: F401 from . import monitors # noqa: F401 # Attaches per-PC credential enrollment, admin and migration routes. from . import enrollment # noqa: F401 +# Attaches the one-line bootstrap installer: token admin plus the two +# token-authenticated download routes. +from . import bootstrap # noqa: F401 __all__ = ['endpoints_bp'] diff --git a/plugins/endpoints/api/bootstrap.py b/plugins/endpoints/api/bootstrap.py new file mode 100644 index 00000000..72039030 --- /dev/null +++ b/plugins/endpoints/api/bootstrap.py @@ -0,0 +1,538 @@ +"""One-line bootstrap installer for the endpoint agent. + +An admin mints a short-lived token in Settings > Endpoints and hands a tech one +command. On a PC with no agent, in an ELEVATED PowerShell: + + powershell -ExecutionPolicy Bypass -c "irm /api/endpoints/siteagent/bootstrap.ps1?t= | iex" + +That installs the agent and tray with NO shared key, so the PC asks to enroll +and waits for an admin to approve it in Settings > Endpoints > Enrollment. This +is the path for a ONE-OFF PC; a managed bay keeps getting the agent from +GE-Enforce, and the generated script says so when it finds an agent already +installed. + +Routes + POST /siteagent/bootstrap/tokens admin, audited; secret once + GET /siteagent/bootstrap/tokens admin; never a secret + POST /siteagent/bootstrap/tokens//revoke admin, audited + GET /siteagent/bootstrap.ps1?t=... token only; not a use + GET /siteagent/bootstrap/package?t=... token only; one use + +The two token-authenticated routes answer 404 for anything not currently +usable - unknown, expired, revoked or exhausted - so the URL confirms nothing +about which tokens exist. Both are per-IP rate limited with the same fixed +window shape the login and payload-download limiters use, and both write an +audit row carrying the caller's IP. +""" + +import hashlib +import json +import re +import secrets +import time +from datetime import datetime, timedelta, timezone + +from flask import Response, current_app, request, send_file + +from shopdb.api import ( + db, cache, AuditLog, Setting, client_ip, error_response, success_response, + ErrorCodes, require_role, +) + +from ..models import EndpointBootstrapToken +from . import siteagent +from .routes import endpoints_bp + +# sdbb_ + 43 chars base64url (32 random bytes, unpadded). Same shape as the +# per-PC credential, different prefix so a leaked value is identifiable. +TOKEN_PREFIX = 'sdbb_' +_TOKEN_PATTERN = re.compile(r'^sdbb_[A-Za-z0-9_-]{43}$') +DISPLAY_PREFIX_LENGTH = 12 + +DEFAULT_HOURS = 24 +DEFAULT_MAX_USES = 25 +MINIMUM_HOURS = 1 +MAXIMUM_HOURS = 168 +MINIMUM_USES = 1 +MAXIMUM_USES = 500 + +LIST_LIMIT = 100 + +SCRIPT_PATH = '/api/endpoints/siteagent/bootstrap.ps1' +PACKAGE_PATH = '/api/endpoints/siteagent/bootstrap/package' + +# Fixed-window per-IP limiter, same shape and cache extension as the login +# limiter in shopdb.core.api.auth and the GE-Enforce payload limiter. A tech +# fetching a script and a package is two hits; a scanner walking token guesses +# is thousands. +BOOTSTRAP_RATELIMIT_MAX = 30 +BOOTSTRAP_RATELIMIT_WINDOW_SECONDS = 60 + +# The endpoint-side force switch. Set it in the same command to reinstall over +# an agent that is already there. +FORCE_VARIABLE = 'SHOPDB_BOOTSTRAP_FORCE' + +_HOST_PATTERN = re.compile(r'^[A-Za-z0-9.-]+(?::[0-9]{1,5})?$') + + +def _naive_utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _caller_ip(): + return client_ip(request) or 'unknown' + + +def _ratelimited(): + """True when this caller is over budget for the current window.""" + if not current_app.config.get('ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED', True): + return False + window = current_app.config.get( + 'ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS', + BOOTSTRAP_RATELIMIT_WINDOW_SECONDS) + maxhits = current_app.config.get('ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX', + BOOTSTRAP_RATELIMIT_MAX) + # Time bucket makes this a fixed window: the key rolls over at each window + # boundary, so a per-hit set() cannot turn it into a sliding window. + bucket = int(time.time() // window) if window > 0 else 0 + key = 'endpointbootstrapratelimit:{0}:{1}'.format(_caller_ip(), bucket) + count = cache.get(key) or 0 + if count >= maxhits: + return True + cache.set(key, count + 1, timeout=window) + return False + + +def _rate_limit_response(): + return error_response('RATE_LIMITED', + 'Too many bootstrap requests. Try again later.', + http_code=429) + + +def _not_found(): + """The single answer for every unusable token, so nothing is confirmed.""" + return error_response(ErrorCodes.NOT_FOUND, 'not found', http_code=404) + + +def _safe_host(value): + host = (value or '').strip() + return host if host and _HOST_PATTERN.match(host) else None + + +def bootstrap_base_url(): + """Base URL baked into the generated command and script. + + Prefers the configured site_base_url, which already carries the scheme and + the mount path. Falls back to the forwarded host (only when a proxy is + actually declared) or the request Host plus the script root, forced to + https - the same resolution the printer installer batch generator uses, + and for the same reason: behind a reverse proxy the app sees http on a + loopback port and its url_root drops the mount. + """ + base = (Setting.get('site_base_url') or '').strip().rstrip('/') + if base: + return base + + host = None + if current_app.config.get('TRUSTED_PROXY_COUNT'): + host = _safe_host(request.headers.get('X-Forwarded-Host')) + if not host: + host = _safe_host(request.host) + + root = (request.script_root or '').rstrip('/') + if not host: + return root + return 'https://{0}{1}'.format(host, root) + + +def bootstrap_command(secret, baseurl=None): + """The one line a tech pastes into an elevated PowerShell.""" + base = (baseurl if baseurl is not None else bootstrap_base_url()).rstrip('/') + return ('powershell -ExecutionPolicy Bypass -c "irm {0}{1}?t={2} | iex"' + .format(base, SCRIPT_PATH, secret)) + + +def _hash_token(secret): + return hashlib.sha256(secret.encode('utf-8')).hexdigest() + + +def _resolve_token(): + """The usable token named by ?t=, else None. + + Unknown, malformed, expired, revoked and exhausted all resolve to None so + the caller can answer 404 for every one of them. + """ + secret = (request.args.get('t') or '').strip() + if not _TOKEN_PATTERN.match(secret): + return None + row = EndpointBootstrapToken.query.filter_by( + tokenhash=_hash_token(secret)).first() + if row is None or not row.isusable(_naive_utcnow()): + return None + return row + + +def _audit(action, row, details): + AuditLog.log(action, 'EndpointBootstrapToken', entityid=row.tokenid, + entityname=row.tokenprefix, + details='{0} from {1}'.format(details, _caller_ip())) + + +# --------------------------------------------------------------------------- +# The generated script +# --------------------------------------------------------------------------- + +# Why the wait loop watches a LOCAL file rather than a server route: there is no +# public "am I approved yet" endpoint, and adding one would hand an anonymous +# caller a way to probe enrollment state. The agent writes credential.bin the +# moment it collects an approved credential, so the file appearing IS the +# enrollment, observed without asking the server anything. +_SCRIPT_TEMPLATE = '''# ShopDB endpoint agent bootstrap +# +# Generated by the ShopDB server for one short-lived install token. Run from an +# ELEVATED Windows PowerShell. It downloads this server's agent package, checks +# the binaries against the hashes stamped in below, installs the agent and tray +# with NO shared key, and leaves the PC waiting for an admin to approve its +# enrollment in Settings > Endpoints > Enrollment. +# +# A managed bay should get the agent from GE-Enforce instead. This is the +# one-off path. + +$ErrorActionPreference = "Stop" + +$BootstrapPackageUrl = {packageurl} +$BootstrapServiceName = "ShopDbEndpointAgent" +$BootstrapInstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent" +$BootstrapAgentSha256 = {agentsha} +$BootstrapTraySha256 = {traysha} +$BootstrapForceVariable = "{forcevariable}" +$BootstrapWaitSeconds = {waitseconds} +$BootstrapCredentialPath = Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential\\credential.bin" + +Write-Host "== ShopDB endpoint agent bootstrap ==" -ForegroundColor Cyan + +# 1. Elevation. Every later step writes HKLM and Program Files. +$bootstrapIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() +$bootstrapPrincipal = New-Object Security.Principal.WindowsPrincipal($bootstrapIdentity) +if (-not $bootstrapPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {{ + Write-Host "This installer must run elevated." -ForegroundColor Red + Write-Host "Open PowerShell as administrator (right-click Windows PowerShell, Run as administrator) and paste the command again." + exit 1 +}} + +# 2. An agent already here means this PC is probably managed. +$bootstrapForce = $false +$bootstrapForceValue = [Environment]::GetEnvironmentVariable($BootstrapForceVariable) +if ($bootstrapForceValue -and $bootstrapForceValue -ne "0") {{ $bootstrapForce = $true }} + +$bootstrapExisting = Get-Service -Name $BootstrapServiceName -ErrorAction SilentlyContinue +if ($bootstrapExisting -and -not $bootstrapForce) {{ + Write-Host ("The ShopDB endpoint agent is already installed here (service " + $BootstrapServiceName + ", " + $bootstrapExisting.Status + ").") -ForegroundColor Yellow + Write-Host "A managed bay should get the agent from GE-Enforce, not from this one-line installer." + Write-Host ("To reinstall anyway, run this first in the same window, then paste the command again:") + Write-Host (" " + '$env:' + $BootstrapForceVariable + " = '1'") + exit 2 +}} + +$bootstrapAlreadyEnrolled = Test-Path $BootstrapCredentialPath + +# 3. Download the package over TLS with normal chain validation. Nothing here +# relaxes certificate checking: a server this PC does not trust must be fixed, +# not bypassed. +try {{ + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +}} +catch {{ }} + +$bootstrapStamp = [DateTime]::UtcNow.ToString("yyyyMMddHHmmssfff") +$bootstrapTempDir = Join-Path $env:TEMP ("shopdb-bootstrap-" + $bootstrapStamp) +$bootstrapZipPath = Join-Path $bootstrapTempDir "package.zip" +$bootstrapExtractDir = Join-Path $bootstrapTempDir "package" + +function Remove-BootstrapTemp {{ + if (Test-Path $bootstrapTempDir) {{ + try {{ Remove-Item -Path $bootstrapTempDir -Recurse -Force -ErrorAction Stop }} + catch {{ Write-Warning ("could not remove " + $bootstrapTempDir + "; delete it manually.") }} + }} +}} + +function Test-BootstrapHash {{ + param( + [Parameter(Mandatory)] [string] $Path, + [AllowEmptyString()] [string] $Expected, + [Parameter(Mandatory)] [string] $Label + ) + $present = Test-Path $Path + if ([string]::IsNullOrWhiteSpace($Expected)) {{ + # The server staged no such binary, so the package must not carry one. + if ($present) {{ + Write-Host ($Label + " is in the package but this server staged none.") -ForegroundColor Red + return $false + }} + Write-Host (" " + $Label + ": not shipped by this server") + return $true + }} + if (-not $present) {{ + Write-Host ($Label + " is missing from the downloaded package.") -ForegroundColor Red + return $false + }} + $actual = (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $Expected.ToLowerInvariant()) {{ + Write-Host ($Label + " does not match the expected SHA256.") -ForegroundColor Red + Write-Host (" expected " + $Expected) + Write-Host (" actual " + $actual) + return $false + }} + Write-Host (" " + $Label + ": sha256 verified") + return $true +}} + +try {{ + New-Item -ItemType Directory -Force -Path $bootstrapTempDir | Out-Null + + Write-Host "[1/4] Downloading the agent package ..." + Invoke-WebRequest -Uri $BootstrapPackageUrl -OutFile $bootstrapZipPath -UseBasicParsing + + Write-Host "[2/4] Expanding and verifying ..." + Expand-Archive -Path $bootstrapZipPath -DestinationPath $bootstrapExtractDir -Force + + $bootstrapOk = $true + if (-not (Test-BootstrapHash -Path (Join-Path $bootstrapExtractDir "shopdbagent.exe") -Expected $BootstrapAgentSha256 -Label "shopdbagent.exe")) {{ $bootstrapOk = $false }} + if (-not (Test-BootstrapHash -Path (Join-Path $bootstrapExtractDir "shopdb-endpoint-tray.exe") -Expected $BootstrapTraySha256 -Label "shopdb-endpoint-tray.exe")) {{ $bootstrapOk = $false }} + if (-not $bootstrapOk) {{ + Remove-BootstrapTemp + Write-Host "Refusing to install an unverified package." -ForegroundColor Red + exit 3 + }} + + $bootstrapInstaller = Join-Path $bootstrapExtractDir "install.ps1" + if (-not (Test-Path $bootstrapInstaller)) {{ + Remove-BootstrapTemp + Write-Host "The downloaded package carries no install.ps1." -ForegroundColor Red + exit 3 + }} + + # No key argument: this package has no -CollectorKey parameter at all. + Write-Host "[3/4] Installing ..." + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $bootstrapInstaller + $bootstrapInstallExit = $LASTEXITCODE +}} +catch {{ + Remove-BootstrapTemp + Write-Host ("Bootstrap failed: " + $_.Exception.Message) -ForegroundColor Red + exit 3 +}} + +Remove-BootstrapTemp + +# 4. Report what actually landed. The installer's own exit code is not enough: +# a PowerShell -File run can return 0 after a terminating error, so the service +# is read back directly. +Write-Host "[4/4] Result" +$bootstrapAgentPath = Join-Path $BootstrapInstallDir "shopdbagent.exe" +$bootstrapVersion = "not installed" +if (Test-Path $bootstrapAgentPath) {{ + $bootstrapVersion = (Get-Item $bootstrapAgentPath).VersionInfo.FileVersion +}} +$bootstrapService = Get-Service -Name $BootstrapServiceName -ErrorAction SilentlyContinue +$bootstrapState = "not installed" +if ($bootstrapService) {{ $bootstrapState = [string] $bootstrapService.Status }} + +Write-Host (" installed version : " + $bootstrapVersion) +Write-Host (" service state : " + $bootstrapState) + +if (-not $bootstrapService -or $bootstrapState -ne "Running") {{ + Write-Host "The agent service is not running. The install did not complete." -ForegroundColor Red + if ($bootstrapInstallExit) {{ Write-Host (" installer exit code: " + $bootstrapInstallExit) }} + exit 4 +}} + +if ($bootstrapAlreadyEnrolled) {{ + Write-Host "This PC already holds an endpoint credential; no approval is needed." -ForegroundColor Green + exit 0 +}} + +Write-Host "This PC is waiting for approval - an admin approves it in Settings > Endpoints > Enrollment." -ForegroundColor Yellow + +# Watch for the credential the agent writes once an admin approves it. Purely +# local: nothing is asked of the server, and a PC still pending after the wait +# is a normal outcome, not a failure. +$bootstrapDeadline = (Get-Date).AddSeconds($BootstrapWaitSeconds) +$bootstrapEnrolled = $false +while ((Get-Date) -lt $bootstrapDeadline) {{ + if (Test-Path $BootstrapCredentialPath) {{ $bootstrapEnrolled = $true; break }} + Start-Sleep -Seconds 5 +}} + +if ($bootstrapEnrolled) {{ + Write-Host "approved and enrolled" -ForegroundColor Green +}} +else {{ + Write-Host "Still pending. The agent keeps asking on its own; nothing else is needed on this PC." +}} + +exit 0 +''' + + +def build_bootstrap_script(secret, baseurl=None): + """The PowerShell text served for one token, with the URL and hashes baked in.""" + base = (baseurl if baseurl is not None else bootstrap_base_url()).rstrip('/') + hashes = siteagent.staged_binary_hashes() + return _SCRIPT_TEMPLATE.format( + packageurl=json.dumps('{0}{1}?t={2}'.format(base, PACKAGE_PATH, + secret)), + agentsha=json.dumps(hashes.get('agent', '')), + traysha=json.dumps(hashes.get('tray', '')), + forcevariable=FORCE_VARIABLE, + waitseconds=120, + ) + + +# --------------------------------------------------------------------------- +# Admin API +# --------------------------------------------------------------------------- + +def _username(): + from flask_jwt_extended import current_user + + return getattr(current_user, 'username', None) + + +@endpoints_bp.route('/siteagent/bootstrap/tokens', methods=['POST']) +@require_role('admin') +def create_bootstrap_token(): + """Mint one short-lived bootstrap token. The secret is returned ONCE.""" + payload = request.get_json(silent=True) or {} + + try: + hours = int(payload.get('hours', DEFAULT_HOURS)) + maxuses = int(payload.get('maxuses', DEFAULT_MAX_USES)) + except (TypeError, ValueError): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hours and maxuses must be whole numbers', + http_code=400) + + if not MINIMUM_HOURS <= hours <= MAXIMUM_HOURS: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'hours must be between {0} and {1}'.format(MINIMUM_HOURS, + MAXIMUM_HOURS), + http_code=400) + if not MINIMUM_USES <= maxuses <= MAXIMUM_USES: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'maxuses must be between {0} and {1}'.format(MINIMUM_USES, + MAXIMUM_USES), + http_code=400) + + secret = TOKEN_PREFIX + secrets.token_urlsafe(32) + now = _naive_utcnow() + row = EndpointBootstrapToken( + tokenhash=_hash_token(secret), + tokenprefix=secret[:DISPLAY_PREFIX_LENGTH], + createdat=now, + createdby=_username(), + expiresat=now + timedelta(hours=hours), + maxuses=maxuses, + usecount=0, + ) + db.session.add(row) + db.session.flush() + _audit('created', row, + 'bootstrap token created, {0}h, {1} use(s)'.format(hours, maxuses)) + db.session.commit() + + return success_response( + { + 'token': row.to_dict(now), + 'secret': secret, + 'command': bootstrap_command(secret), + }, + message='Bootstrap token created. The command is shown once.', + http_code=201) + + +@endpoints_bp.route('/siteagent/bootstrap/tokens', methods=['GET']) +@require_role('admin') +def list_bootstrap_tokens(): + """Every bootstrap token, newest first. Never a secret.""" + now = _naive_utcnow() + rows = (EndpointBootstrapToken.query + .order_by(EndpointBootstrapToken.tokenid.desc()) + .limit(LIST_LIMIT).all()) + return success_response({'tokens': [row.to_dict(now) for row in rows]}) + + +@endpoints_bp.route('/siteagent/bootstrap/tokens//revoke', + methods=['POST']) +@require_role('admin') +def revoke_bootstrap_token(tokenid): + """Kill a bootstrap token now. Already-revoked is a no-op, not an error.""" + row = db.session.get(EndpointBootstrapToken, tokenid) + if row is None: + return error_response(ErrorCodes.NOT_FOUND, 'not found', http_code=404) + now = _naive_utcnow() + if row.revokedat is None: + row.revokedat = now + _audit('updated', row, 'bootstrap token revoked') + db.session.commit() + return success_response({'token': row.to_dict(now)}, + message='Bootstrap token revoked') + + +# --------------------------------------------------------------------------- +# Token-authenticated: no login, the token in ?t= is the whole authorization +# --------------------------------------------------------------------------- + +@endpoints_bp.route('/siteagent/bootstrap.ps1', methods=['GET']) +def serve_bootstrap_script(): + """The PowerShell the one-line command pipes into iex. + + Fetching the script does NOT spend a use: a tech who pastes the command + twice, or a shell that retries, must not burn the budget the package + download is metered by. + """ + if _ratelimited(): + return _rate_limit_response() + row = _resolve_token() + if row is None: + return _not_found() + + secret = (request.args.get('t') or '').strip() + _audit('requested', row, 'bootstrap script served') + db.session.commit() + + return Response(build_bootstrap_script(secret), mimetype='text/plain') + + +@endpoints_bp.route('/siteagent/bootstrap/package', methods=['GET']) +def download_bootstrap_package(): + """The keyless agent package the bootstrap script installs. + + Never carries a collector key, whatever the shared-key setting says: a + bootstrap PC enrolls and waits for approval. Counts as one use. + """ + if _ratelimited(): + return _rate_limit_response() + row = _resolve_token() + if row is None: + return _not_found() + + now = _naive_utcnow() + row.usecount = (row.usecount or 0) + 1 + row.lastusedat = now + row.lastusedip = _caller_ip()[:45] + _audit('downloaded', row, 'bootstrap package, use {0} of {1}'.format( + row.usecount, row.maxuses)) + db.session.commit() + + buffer, warning = siteagent.build_keyless_package(bootstrap_base_url()) + response = send_file( + buffer, + mimetype='application/zip', + as_attachment=True, + download_name=siteagent.package_download_name()) + if warning: + response.headers[siteagent.CA_WARNING_HEADER] = warning + return response diff --git a/plugins/endpoints/api/siteagent.py b/plugins/endpoints/api/siteagent.py index a498f9eb..6c6b4527 100644 --- a/plugins/endpoints/api/siteagent.py +++ b/plugins/endpoints/api/siteagent.py @@ -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', diff --git a/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md b/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md index 41ee05ac..9b3956e7 100644 --- a/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md +++ b/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md @@ -272,6 +272,42 @@ Repeat until **Ready** is 0. turned off (phase 6) that is the only path, and the steady-state path for new PCs. +## Installing a one-off PC + +A PC nobody is going to author a GE-Enforce manifest entry for - a loaner, a +one-off office machine, a bay being rebuilt out of band - can be installed with +a single command instead of a downloaded package. **Managed bays keep using +GE-Enforce**; this does not replace the share channel, and the script itself +says so when it finds an agent already installed. + +1. Settings -> Endpoints -> Build site agent -> **One-line install**. Set how + long the token lives (hours, 1 to 168) and how many installs it may serve + (1 to 500), then **Generate install command**. +2. Copy the command. The token is shown ONCE and cannot be recovered; generate + a new one if it is lost. +3. On the target PC, open PowerShell **as administrator** and paste it: + +``` +powershell -ExecutionPolicy Bypass -c "irm https:///api/endpoints/siteagent/bootstrap.ps1?t= | iex" +``` + +The token is short-lived and only fetches the installer. It carries no shared +key and provisions nothing by itself: the package it downloads is built with +"Include shared key" off, whatever the shared-key setting is, so the PC asks to +enroll and **waits for approval**. Approve it in Enrollment -> Credentials -> +Requests, the same as any other pending PC (phase 5). + +The script checks the downloaded binaries against the SHA-256 values the server +stamped into it, refuses to run unelevated, and refuses to run on a PC that +already has the agent service (set `SHOPDB_BOOTSTRAP_FORCE=1` in the same +window to override). After a successful install it waits up to two minutes for +the credential to land and prints `approved and enrolled` if an admin approves +it in that window; still pending is a normal outcome, not a failure. + +Revoke a token from the same block when the install round is done. Tokens list +with their prefix, expiry and uses; they are also spent automatically +(`usecount` reaching `maxuses`) and expire on their own. + ## Phase 6: cutover **Gate:** Credentials tab **Not enrolled** is 0, or every remaining PC is one diff --git a/plugins/endpoints/frontend/views/AgentKeySettings.vue b/plugins/endpoints/frontend/views/AgentKeySettings.vue index 3941985a..e0d7ea86 100644 --- a/plugins/endpoints/frontend/views/AgentKeySettings.vue +++ b/plugins/endpoints/frontend/views/AgentKeySettings.vue @@ -380,6 +380,134 @@

{{ agentwarning }}

{{ agenterror }}

+ +
+

One-line install

+

+ Generate a short-lived token and give a technician the command. + On a PC with no agent, run it from an elevated + PowerShell. The token only fetches the installer: it carries no + shared key and provisions nothing on its own. A managed bay should + keep getting the agent from GE-Enforce instead. +

+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ + + +

+ The PC installs with no shared key and then waits for approval in + Settings > Endpoints > Enrollment. +

+ + + + + + + + + + + + + + + + + + + + +
TokenStateExpiresUses
{{ entry.tokenprefix }}{{ entry.state }}{{ formatDate(entry.expiresat) }}{{ entry.usecount }} / {{ entry.maxuses }} + +
+

No install tokens yet.

+ +

+ {{ bootstrapmessage }} +

+

{{ bootstraperror }}

+
+