Install an agent on a one-off PC from a single command
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:
538
plugins/endpoints/api/bootstrap.py
Normal file
538
plugins/endpoints/api/bootstrap.py
Normal file
@@ -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 <base>/api/endpoints/siteagent/bootstrap.ps1?t=<token> | 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/<id>/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/<int:tokenid>/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
|
||||
Reference in New Issue
Block a user