"""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: the command downloads bootstrap.ps1 to a file, runs it, and deletes it again. 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 token only; not a use GET /siteagent/bootstrap/package token only; one use The two token-authenticated routes take the token in the X-Endpoint-Bootstrap-Token header, and still accept the deprecated ?t= form so a command already pasted on a bay keeps working. They answer 404 for anything not currently usable - unknown, malformed, expired, revoked, exhausted, or no token presented at all - so the URL confirms nothing about which tokens exist, nor about whether the query form is still accepted here. The one 400 is a real query token offered at a site that has turned the query form off. The admin UI's Download .ps1 link has to use the query form (a browser link cannot send a header), so its URL carries a marker that tells it apart from a bay still running a command pasted before this change. Only the bay logs the deprecation warning, which is what the rollout counts un-migrated bays with. Both routes 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 from datetime import datetime, timedelta, timezone from flask import Response, current_app, request, send_file from shopdb.api import ( db, AuditLog, Setting, client_ip, error_response, success_response, ErrorCodes, require_role, ) from ..models import EndpointBootstrapToken from . import siteagent from .ratelimit import ratelimited, rate_limit_response 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' # Where the one-line command stages the script before running it. A literal, # not $env:TEMP: the line has to survive being pasted into an already-running # PowerShell, which expands every $ inside the outer double quotes before the # child process sees them. # # NOT C:\Windows\Temp. That directory grants Users create-file and gives # CREATOR OWNER full control of what they create, so any standard user on the # bay can pre-plant this exact path and wait for a tech to paste the command, # which then runs the planted content elevated. Program Files is admin-only on # a stock install, the command re-asserts an admin-only DACL on the directory # it creates, and it refuses to run a staged file owned by anyone else. COMMAND_STAGE_DIR = r'C:\Program Files\ShopDB\bootstrap' COMMAND_SCRIPT_PATH = COMMAND_STAGE_DIR + r'\shopdb-bootstrap.ps1' # Well-known SIDs, not names: the bays are domain-joined and a renamed or # localized Administrators group would quietly break both the grant and the # ownership test. ADMINISTRATORS_SID = 'S-1-5-32-544' SYSTEM_SID = 'S-1-5-18' # The token the two public routes accept. A header, not ?t=: a query string # lands verbatim in the reverse proxy and gunicorn access logs, and a live # install token has no business sitting in a log file. BOOTSTRAP_TOKEN_HEADER = 'X-Endpoint-Bootstrap-Token' QUERY_TOKEN_ARG = 't' # Where the token on this request came from. Kept apart because the rollout # counts un-migrated bays by the deprecation warning SOURCE_QUERY logs: an # admin clicking Download .ps1 in the browser must not land in that count, and # a request carrying no token at all must not answer differently from one # carrying a token nobody issued. SOURCE_HEADER = 'header' SOURCE_QUERY = 'query' SOURCE_UI_LINK = 'uilink' SOURCE_NONE = 'none' # The marker on the admin Download .ps1 link. A hint, NOT authentication: a # caller can add it by hand, and nothing is granted by it. What matters is the # other direction - a one-line command generated before this change cannot # carry it, so a request without it really is a bay to go and re-issue. UI_LINK_ARG = 'via' UI_LINK_VALUE = 'download' # DEPRECATED, kept on purpose: every command already pasted on a bay, and the # UI's "Download .ps1" link (a browser link cannot send a header), carry the # token in the query string. Accepting it stays the default so a mid-rollout # bay keeps working; a site that has re-issued its live commands turns it off. # Turning it off also disables the download link. # # Declared in the endpoints plugin's get_settings_defaults() (so the row seeds # boolean, not string) and flipped from Settings > Endpoints > Build site agent # > One-line install, "Accept the token in the URL". A switch with no seed and # no control would be inert on an air-gapped box. QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken' # 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(): """The caller address for the audit rows and the deprecation warning. Read the value before trusting it to identify a bay. client_ip() returns remote_addr and nothing else. On the shipped Windows deploy IIS launches waitress on loopback through HttpPlatformHandler, and deploy/windows/ web.config ships the X-Forwarded-For rewrite rule COMMENTED OUT - IIS sets no such header on its own, so waitress's --trusted-proxy=127.0.0.1 --trusted-proxy-count=1 has nothing to rewrite REMOTE_ADDR from and leaves it as the loopback peer. TRUSTED_PROXY_COUNT also defaults to 0, so ProxyFix is not installed either. Every row and every warning then reads 127.0.0.1, and the un-migrated-bay list identifies no bay. It is a real address only where the site enabled that rewrite rule (the installer's -ClientIpSource direct), or set TRUSTED_PROXY_COUNT for a front proxy that sets the header itself. """ return client_ip(request) or 'unknown' def _ratelimited(): """True when this caller is over budget for the current window. The implementation moved to .ratelimit so the enrollment route shares it rather than carrying a second copy. Budget name, config keys and defaults are unchanged, so the behaviour here is identical. """ return ratelimited('endpointbootstrapratelimit', 'ENDPOINTS_BOOTSTRAP_RATELIMIT', BOOTSTRAP_RATELIMIT_MAX, BOOTSTRAP_RATELIMIT_WINDOW_SECONDS) def _rate_limit_response(): return rate_limit_response('Too many bootstrap requests. Try again later.') 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 _base(baseurl=None): resolved = baseurl if baseurl is not None else bootstrap_base_url() return resolved.rstrip('/') def bootstrap_script_url(secret, baseurl=None): """The UI's "Download .ps1" link: a plain file a tech can save and run. Still the query-string form, because an cannot set a header. The one-line command no longer uses this shape - see bootstrap_command. It carries UI_LINK_ARG so the server can tell this click apart from a bay running a pre-change command. Without that the admin's own Download link fires the deprecation warning the rollout uses to FIND un-migrated bays, and the count becomes noise the moment an admin mints a token. """ return '{0}{1}?{2}={3}&{4}={5}'.format(_base(baseurl), SCRIPT_PATH, QUERY_TOKEN_ARG, secret, UI_LINK_ARG, UI_LINK_VALUE) def bootstrap_fetch_url(path, baseurl=None): """A token-free URL for the machine-to-machine fetches. The token rides in BOOTSTRAP_TOKEN_HEADER so it never reaches an access log.""" return '{0}{1}'.format(_base(baseurl), path) def bootstrap_command(secret, baseurl=None): """The one line a tech pastes into an elevated PowerShell. Download to a file and run it, never `irm ... | iex`: Microsoft Defender scores the piped-to-iex shape as Trojan:Win32/Commando.A!ml and kills the process before a line of it runs. Every bay runs Defender. Single quotes inside and no $ anywhere, so the one line means the same whether it is pasted into cmd.exe or into a PowerShell prompt. The download and the run are wrapped in try/finally so the staged file goes away even when the script exits non-zero or the download itself fails. Staging is the part worth being careful about, so the line creates COMMAND_STAGE_DIR with an admin-only DACL, clears any leftover file, and refuses (exit 5) to run a staged script that some other principal owns - which is what a pre-planted file looks like. Well-known SIDs throughout, and the owner may also be the elevated caller, since a file created by an administrator is owned by that account rather than by the group. The hardening is also CHECKED (exit 6). icacls is a native command, so it reports failure only in an exit code: no exception is raised, and $ErrorActionPreference never applies to it. Unchecked, an icacls that cannot write the DACL leaves the directory inheriting whatever the parent grants and the line downloads into it anyway - the silent version of the hole this staging move was made to close. The same check also covers the New-Item before it, because icacls on a directory that was never created exits non-zero too. Read as `Get-Variable LASTEXITCODE -ValueOnly`, not `$LASTEXITCODE`: the whole line must stay free of `$`, or a paste into an already-running PowerShell expands it in the PARENT and the child is handed `-ne 0` with the parent's last exit code baked in. """ return ( 'powershell -ExecutionPolicy Bypass -c "' "try {{ New-Item -ItemType Directory -Force -Path '{stagedir}' " '| Out-Null; ' "icacls '{stagedir}' /inheritance:r /grant '*{admin}:(OI)(CI)F' " "'*{system}:(OI)(CI)F' | Out-Null; " 'if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) ' "{{ Write-Host 'Refusing to run: could not restrict " "{stagedir} to administrators (icacls failed). Fix the directory " "or delete it, then run the command again.' -ForegroundColor Red; " 'exit 6 }}; ' "Remove-Item '{path}' -Force -ErrorAction SilentlyContinue; " "Invoke-WebRequest -UseBasicParsing '{url}' " "-Headers @{{'{header}'='{secret}'}} -OutFile '{path}'; " "if ((Get-Acl '{path}').GetOwner(" '[Security.Principal.SecurityIdentifier]).Value -notin ' "@('{admin}','{system}'," '[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) ' "{{ Write-Host 'Refusing to run: the staged bootstrap script is not " "owned by an administrator.' -ForegroundColor Red; exit 5 }}; " "& '{path}' }} finally {{ Remove-Item '{path}' -Force " '-ErrorAction SilentlyContinue }}"' ).format(stagedir=COMMAND_STAGE_DIR, path=COMMAND_SCRIPT_PATH, url=bootstrap_fetch_url(SCRIPT_PATH, baseurl), header=BOOTSTRAP_TOKEN_HEADER, secret=secret, admin=ADMINISTRATORS_SID, system=SYSTEM_SID) def _hash_token(secret): return hashlib.sha256(secret.encode('utf-8')).hexdigest() def _presented_token(): """(secret, source) for the token this request carries. Header first, query string second. The query form is deprecated but still the only one a browser download link can use. SOURCE_NONE when nothing was presented, which is NOT the same as an empty query token: reporting a bare GET as 'query' made it answer 400 naming the header, telling an anonymous caller whether this site had turned the query form off, and contradicting the 404-for-everything-unusable rule at the top of this module. """ header = (request.headers.get(BOOTSTRAP_TOKEN_HEADER) or '').strip() if header: return header, SOURCE_HEADER query = (request.args.get(QUERY_TOKEN_ARG) or '').strip() if not query: return '', SOURCE_NONE if request.args.get(UI_LINK_ARG) == UI_LINK_VALUE: return query, SOURCE_UI_LINK return query, SOURCE_QUERY def _is_query_source(source): """Both query-string forms, the bay's and the admin download link's. The setting is about a token in an access log, and the admin link puts one there just as surely as a pasted command does.""" return source in (SOURCE_QUERY, SOURCE_UI_LINK) def _query_token_allowed(): """Defaults to True: today's pasted commands and the UI download link both use the query form, and turning that off mid-rollout strands a bay.""" return bool(Setting.get(QUERY_TOKEN_SETTING, True)) def _query_token_refused(): """Answered before any lookup, so it confirms nothing about any token.""" return error_response( ErrorCodes.VALIDATION_ERROR, 'Send the bootstrap token in the {0} header.'.format( BOOTSTRAP_TOKEN_HEADER), http_code=400) def _note_token_source(source): """Audit suffix for a token that really resolved, and, for a query-string token, one log line naming the caller and WHICH query path it was. Only the bay warns. That warning is the rollout's un-migrated-bay counter (see PER-PC-CREDENTIALS-ROLLOUT.md), so the admin's own Download .ps1 link logs at info instead - otherwise every mint-and-test by an admin adds a bay that does not exist. Never the token itself, in either line: keeping it out of the logs is the whole point of the header form. """ if source == SOURCE_UI_LINK: current_app.logger.info( 'bootstrap script fetched by %s from the admin Download .ps1 ' 'link; query-string token, but the admin browser path, NOT an ' 'un-migrated bay', _caller_ip()) return ' (query-string token, admin download link)' if source == SOURCE_QUERY: current_app.logger.warning( 'bootstrap token presented in the query string by %s with no ' 'admin download marker, so this is an un-migrated bay running a ' 'command pasted before the header form; that form is deprecated ' 'and slated for removal, re-issue the one-line install command', _caller_ip()) return ' (query-string token, deprecated)' return '' def _resolve_token(secret): """The usable token with this secret, else None. Unknown, malformed, expired, revoked and exhausted all resolve to None so the caller can answer 404 for every one of them. """ 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} $BootstrapPackageHeaders = @{{ {tokenheader} = {token} }} $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 -Headers $BootstrapPackageHeaders -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 = _base(baseurl) hashes = siteagent.staged_binary_hashes() return _SCRIPT_TEMPLATE.format( packageurl=json.dumps(bootstrap_fetch_url(PACKAGE_PATH, base)), tokenheader=json.dumps(BOOTSTRAP_TOKEN_HEADER), token=json.dumps(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), 'scripturl': bootstrap_script_url(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 is the whole authorization. It # rides in the X-Endpoint-Bootstrap-Token header; ?t= is still accepted. # --------------------------------------------------------------------------- @endpoints_bp.route('/siteagent/bootstrap.ps1', methods=['GET']) def serve_bootstrap_script(): """The PowerShell the one-line command downloads, runs and deletes. The same URL is the UI's "Download .ps1" link, for a tech who would rather save the file and run it than paste a command. 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() secret, source = _presented_token() if _is_query_source(source) and not _query_token_allowed(): return _query_token_refused() row = _resolve_token(secret) if row is None: return _not_found() _audit('requested', row, 'bootstrap script served' + _note_token_source(source)) 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() secret, source = _presented_token() if _is_query_source(source) and not _query_token_allowed(): return _query_token_refused() row = _resolve_token(secret) 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}{2}'.format( row.usecount, row.maxuses, _note_token_source(source))) 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