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:
@@ -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
|
`{"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
|
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.
|
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 <baseurl>/api/endpoints/siteagent/bootstrap.ps1?t=<token> | 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/<tokenid>/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.
|
||||||
|
|||||||
@@ -11,5 +11,8 @@ from . import agentupload # noqa: F401
|
|||||||
from . import monitors # noqa: F401
|
from . import monitors # noqa: F401
|
||||||
# Attaches per-PC credential enrollment, admin and migration routes.
|
# Attaches per-PC credential enrollment, admin and migration routes.
|
||||||
from . import enrollment # noqa: F401
|
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']
|
__all__ = ['endpoints_bp']
|
||||||
|
|||||||
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
|
||||||
@@ -29,6 +29,7 @@ lowercase hex SHA-256 of the leaf SubjectPublicKeyInfo), optionally with a
|
|||||||
second "next" pin for a planned certificate rotation.
|
second "next" pin for a planned certificate rotation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
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():
|
def _siteagent_status_payload():
|
||||||
"""Whether a prebuilt agent binary is staged for packaging.
|
"""Whether a prebuilt agent binary is staged for packaging.
|
||||||
|
|
||||||
@@ -1035,41 +1139,18 @@ def package_siteagent():
|
|||||||
serverpins = list(dict.fromkeys(serverpins))
|
serverpins = list(dict.fromkeys(serverpins))
|
||||||
appsettings['serverpins'] = serverpins
|
appsettings['serverpins'] = serverpins
|
||||||
|
|
||||||
buffer = io.BytesIO()
|
buffer = build_package_zip(
|
||||||
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
|
baseurl, collectorkey, cadenceseconds, appsettings,
|
||||||
archive.writestr('appsettings.json',
|
certfilename=certfilename, cert_pem=cert_pem, certpath=certpath,
|
||||||
json.dumps(appsettings, indent=2) + '\n')
|
serverpins=serverpins, includesharedkey=includesharedkey,
|
||||||
archive.writestr('install.ps1',
|
trustcertinroot=trustcertinroot)
|
||||||
_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)
|
|
||||||
# Stamp the staged agent version into the download filename so a DT operator
|
# 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:
|
# 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
|
# 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
|
# name never affects the install. Falls back to the unversioned name when no
|
||||||
# managed version is current (only a legacy flat file is staged).
|
# managed version is current (only a legacy flat file is staged).
|
||||||
agentversion = agentstore.current_version('agent')
|
download_name = package_download_name()
|
||||||
download_name = ('shopdb-endpoint-agent-{0}.zip'.format(agentversion)
|
|
||||||
if agentversion else 'shopdb-endpoint-agent.zip')
|
|
||||||
response = send_file(
|
response = send_file(
|
||||||
buffer,
|
buffer,
|
||||||
mimetype='application/zip',
|
mimetype='application/zip',
|
||||||
|
|||||||
@@ -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
|
turned off (phase 6) that is the only path, and the steady-state path for new
|
||||||
PCs.
|
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://<server>/api/endpoints/siteagent/bootstrap.ps1?t=<token> | 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
|
## Phase 6: cutover
|
||||||
|
|
||||||
**Gate:** Credentials tab **Not enrolled** is 0, or every remaining PC is one
|
**Gate:** Credentials tab **Not enrolled** is 0, or every remaining PC is one
|
||||||
|
|||||||
@@ -380,6 +380,134 @@
|
|||||||
<p v-if="agentwarning" class="field-warning">{{ agentwarning }}</p>
|
<p v-if="agentwarning" class="field-warning">{{ agentwarning }}</p>
|
||||||
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
|
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
One-line install. An admin mints a short-lived token and hands a
|
||||||
|
tech one command; the token only fetches the installer. The
|
||||||
|
package it serves never carries the shared key, so the PC waits
|
||||||
|
in Pending until an admin approves its enrollment. -->
|
||||||
|
<div class="card bootstrap-block">
|
||||||
|
<h4>One-line install</h4>
|
||||||
|
<p class="field-hint">
|
||||||
|
Generate a short-lived token and give a technician the command.
|
||||||
|
On a PC with no agent, run it from an <strong>elevated</strong>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bootstrap-inputs">
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="bootstrap-hours">
|
||||||
|
Valid for (hours)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bootstrap-hours"
|
||||||
|
v-model.number="bootstraphours"
|
||||||
|
class="form-control"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="168"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="bootstrap-maxuses">
|
||||||
|
Maximum uses
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bootstrap-maxuses"
|
||||||
|
v-model.number="bootstrapmaxuses"
|
||||||
|
class="form-control"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="bootstrapbusy"
|
||||||
|
@click="generateBootstrapToken"
|
||||||
|
>
|
||||||
|
{{ bootstrapbusy ? 'Generating...' : 'Generate install command' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="bootstrapcommand">
|
||||||
|
<p class="field-warning">
|
||||||
|
Copy this now. The token is shown once and cannot be recovered.
|
||||||
|
</p>
|
||||||
|
<label class="field-label" for="bootstrap-command">
|
||||||
|
Install command
|
||||||
|
</label>
|
||||||
|
<div class="bootstrap-command-row">
|
||||||
|
<input
|
||||||
|
id="bootstrap-command"
|
||||||
|
class="form-control mono"
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
:value="bootstrapcommand"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
@click="copyBootstrapCommand"
|
||||||
|
>
|
||||||
|
{{ bootstrapcopied ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="bootstraptoken" class="field-hint">
|
||||||
|
Expires {{ formatDate(bootstraptoken.expiresat) }}.
|
||||||
|
{{ bootstraptoken.remaining }} of
|
||||||
|
{{ bootstraptoken.maxuses }} uses remaining.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p class="field-hint">
|
||||||
|
The PC installs with no shared key and then waits for approval in
|
||||||
|
Settings > Endpoints > Enrollment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table v-if="bootstraptokens.length" class="versions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Token</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Uses</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="entry in bootstraptokens" :key="entry.tokenid">
|
||||||
|
<td class="mono">{{ entry.tokenprefix }}</td>
|
||||||
|
<td>{{ entry.state }}</td>
|
||||||
|
<td>{{ formatDate(entry.expiresat) }}</td>
|
||||||
|
<td>{{ entry.usecount }} / {{ entry.maxuses }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
v-if="entry.state === 'active'"
|
||||||
|
class="btn-link"
|
||||||
|
type="button"
|
||||||
|
:disabled="bootstrapbusy"
|
||||||
|
@click="revokeBootstrapToken(entry.tokenid)"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else class="field-hint">No install tokens yet.</p>
|
||||||
|
|
||||||
|
<p v-if="bootstrapmessage" class="field-hint">
|
||||||
|
{{ bootstrapmessage }}
|
||||||
|
</p>
|
||||||
|
<p v-if="bootstraperror" class="field-error">{{ bootstraperror }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
Agent binary versions: upload a new build, keep the last few,
|
Agent binary versions: upload a new build, keep the last few,
|
||||||
and roll the current pointer back to a retained version. Staging
|
and roll the current pointer back to a retained version. Staging
|
||||||
@@ -610,6 +738,19 @@ const agentincludesharedkey = ref(true)
|
|||||||
const agentnextserverpin = ref('')
|
const agentnextserverpin = ref('')
|
||||||
const agentserverpin = ref('')
|
const agentserverpin = ref('')
|
||||||
|
|
||||||
|
// One-line install tokens. bootstrapcommand holds the secret ONLY for the
|
||||||
|
// response that minted it: the list read never carries one, so a reload leaves
|
||||||
|
// the field empty rather than re-showing a token nobody can recover.
|
||||||
|
const bootstraphours = ref(24)
|
||||||
|
const bootstrapmaxuses = ref(25)
|
||||||
|
const bootstrapbusy = ref(false)
|
||||||
|
const bootstrapcommand = ref('')
|
||||||
|
const bootstraptoken = ref(null)
|
||||||
|
const bootstrapcopied = ref(false)
|
||||||
|
const bootstraptokens = ref([])
|
||||||
|
const bootstrapmessage = ref('')
|
||||||
|
const bootstraperror = ref('')
|
||||||
|
|
||||||
function agentnextpininvalid() {
|
function agentnextpininvalid() {
|
||||||
const pin = agentnextserverpin.value
|
const pin = agentnextserverpin.value
|
||||||
return !!pin && !/^[0-9a-f]{64}$/.test(pin)
|
return !!pin && !/^[0-9a-f]{64}$/.test(pin)
|
||||||
@@ -708,6 +849,7 @@ onMounted(async () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
loadEnrollment()
|
loadEnrollment()
|
||||||
|
loadBootstrapTokens()
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/endpoints/siteagent')
|
const response = await api.get('/endpoints/siteagent')
|
||||||
const status = response.data.data || {}
|
const status = response.data.data || {}
|
||||||
@@ -723,6 +865,68 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadBootstrapTokens() {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/endpoints/siteagent/bootstrap/tokens')
|
||||||
|
bootstraptokens.value = response.data.data?.tokens || []
|
||||||
|
} catch (loadError) {
|
||||||
|
bootstraptokens.value = []
|
||||||
|
console.error(loadError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateBootstrapToken() {
|
||||||
|
bootstrapbusy.value = true
|
||||||
|
bootstrapmessage.value = ''
|
||||||
|
bootstraperror.value = ''
|
||||||
|
bootstrapcommand.value = ''
|
||||||
|
bootstraptoken.value = null
|
||||||
|
bootstrapcopied.value = false
|
||||||
|
try {
|
||||||
|
const response = await api.post('/endpoints/siteagent/bootstrap/tokens', {
|
||||||
|
hours: bootstraphours.value,
|
||||||
|
maxuses: bootstrapmaxuses.value
|
||||||
|
})
|
||||||
|
const data = response.data.data || {}
|
||||||
|
bootstrapcommand.value = data.command || ''
|
||||||
|
bootstraptoken.value = data.token || null
|
||||||
|
bootstrapmessage.value = 'Install command generated.'
|
||||||
|
await loadBootstrapTokens()
|
||||||
|
} catch (generateError) {
|
||||||
|
bootstraperror.value = generateError.response?.data?.data?.error?.message
|
||||||
|
|| 'Could not generate an install token'
|
||||||
|
console.error(generateError)
|
||||||
|
} finally {
|
||||||
|
bootstrapbusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyBootstrapCommand() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(bootstrapcommand.value)
|
||||||
|
bootstrapcopied.value = true
|
||||||
|
} catch (copyError) {
|
||||||
|
console.error(copyError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeBootstrapToken(tokenid) {
|
||||||
|
bootstrapbusy.value = true
|
||||||
|
bootstrapmessage.value = ''
|
||||||
|
bootstraperror.value = ''
|
||||||
|
try {
|
||||||
|
await api.post(`/endpoints/siteagent/bootstrap/tokens/${tokenid}/revoke`)
|
||||||
|
bootstrapmessage.value = 'Install token revoked.'
|
||||||
|
await loadBootstrapTokens()
|
||||||
|
} catch (revokeError) {
|
||||||
|
bootstraperror.value = revokeError.response?.data?.data?.error?.message
|
||||||
|
|| 'Could not revoke the install token'
|
||||||
|
console.error(revokeError)
|
||||||
|
} finally {
|
||||||
|
bootstrapbusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyBinaryStatus(binkey, current, versions) {
|
function applyBinaryStatus(binkey, current, versions) {
|
||||||
const binary = binaryFor(binkey)
|
const binary = binaryFor(binkey)
|
||||||
if (!binary) return
|
if (!binary) return
|
||||||
@@ -1131,6 +1335,23 @@ function editConfiguration() {
|
|||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
border-bottom: 1px solid var(--color-border, #333);
|
border-bottom: 1px solid var(--color-border, #333);
|
||||||
}
|
}
|
||||||
|
.bootstrap-block {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.bootstrap-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.bootstrap-inputs .form-control { max-width: 9rem; }
|
||||||
|
.bootstrap-command-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.bootstrap-command-row .form-control { flex: 1; }
|
||||||
.current-tag {
|
.current-tag {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-success, #4caf50);
|
color: var(--color-success, #4caf50);
|
||||||
|
|||||||
189
plugins/endpoints/frontend/views/agentBootstrapInstall.spec.js
Normal file
189
plugins/endpoints/frontend/views/agentBootstrapInstall.spec.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* One-line install block on the endpoints agent key page.
|
||||||
|
*
|
||||||
|
* The token is shown exactly once, so the two things worth pinning are that the
|
||||||
|
* command rendered is the one the server returned, and that reloading the page
|
||||||
|
* does NOT put a token back on screen - the list read carries no secret, and a
|
||||||
|
* field that repopulated from it would be lying about what is recoverable.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount, flushPromises, RouterLinkStub } from '@vue/test-utils'
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }
|
||||||
|
}))
|
||||||
|
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import AgentKeySettings from './AgentKeySettings.vue'
|
||||||
|
|
||||||
|
const TOKENS_URL = '/endpoints/siteagent/bootstrap/tokens'
|
||||||
|
const SECRET = 'sdbb_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG'
|
||||||
|
const COMMAND =
|
||||||
|
'powershell -ExecutionPolicy Bypass -c "irm https://shopdb.test/shopdb'
|
||||||
|
+ `/api/endpoints/siteagent/bootstrap.ps1?t=${SECRET} | iex"`
|
||||||
|
|
||||||
|
const MINTED_TOKEN = {
|
||||||
|
tokenid: 7,
|
||||||
|
tokenprefix: 'sdbb_012345',
|
||||||
|
createdat: '2026-09-15T10:00:00Z',
|
||||||
|
expiresat: '2026-09-16T10:00:00Z',
|
||||||
|
maxuses: 25,
|
||||||
|
usecount: 0,
|
||||||
|
remaining: 25,
|
||||||
|
revokedat: null,
|
||||||
|
state: 'active'
|
||||||
|
}
|
||||||
|
|
||||||
|
function listResponse(tokens) {
|
||||||
|
return { data: { data: { tokens } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(tokens = []) {
|
||||||
|
api.get.mockImplementation((path) => {
|
||||||
|
if (path === TOKENS_URL) return Promise.resolve(listResponse(tokens))
|
||||||
|
if (path === '/endpoints/agentkey') {
|
||||||
|
return Promise.resolve({ data: { data: { key: null, envkeyfallback: false } } })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: { data: {} } })
|
||||||
|
})
|
||||||
|
const wrapper = mount(AgentKeySettings, {
|
||||||
|
global: { stubs: { RouterLink: RouterLinkStub } }
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateButton(wrapper) {
|
||||||
|
return wrapper.findAll('button')
|
||||||
|
.find((button) => button.text() === 'Generate install command')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AgentKeySettings one-line install', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
api.get.mockReset()
|
||||||
|
api.post.mockReset()
|
||||||
|
api.delete.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says the PC waits for approval and that managed bays use GE-Enforce', async () => {
|
||||||
|
const wrapper = await render()
|
||||||
|
const block = wrapper.find('.bootstrap-block').text()
|
||||||
|
expect(block).toContain('One-line install')
|
||||||
|
expect(block).toMatch(/waits for approval in\s+Settings > Endpoints > Enrollment/)
|
||||||
|
expect(block).toContain('GE-Enforce')
|
||||||
|
expect(block).toContain('elevated')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generate posts the hours and maximum uses', async () => {
|
||||||
|
api.post.mockResolvedValue({
|
||||||
|
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||||
|
})
|
||||||
|
const wrapper = await render()
|
||||||
|
|
||||||
|
await wrapper.find('#bootstrap-hours').setValue(48)
|
||||||
|
await wrapper.find('#bootstrap-maxuses').setValue(5)
|
||||||
|
await generateButton(wrapper).trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 48, maxuses: 5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to 24 hours and 25 uses', async () => {
|
||||||
|
api.post.mockResolvedValue({
|
||||||
|
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||||
|
})
|
||||||
|
const wrapper = await render()
|
||||||
|
|
||||||
|
expect(wrapper.find('#bootstrap-hours').element.value).toBe('24')
|
||||||
|
expect(wrapper.find('#bootstrap-maxuses').element.value).toBe('25')
|
||||||
|
|
||||||
|
await generateButton(wrapper).trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 24, maxuses: 25 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the returned command, the once-only warning and the expiry', async () => {
|
||||||
|
api.post.mockResolvedValue({
|
||||||
|
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||||
|
})
|
||||||
|
const wrapper = await render()
|
||||||
|
await generateButton(wrapper).trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const field = wrapper.find('#bootstrap-command')
|
||||||
|
expect(field.exists()).toBe(true)
|
||||||
|
expect(field.element.value).toBe(COMMAND)
|
||||||
|
expect(field.element.value).toContain(SECRET)
|
||||||
|
expect(field.attributes('readonly')).toBeDefined()
|
||||||
|
const block = wrapper.find('.bootstrap-block').text()
|
||||||
|
expect(block).toContain('The token is shown once and cannot be recovered')
|
||||||
|
expect(block).toContain('25 of')
|
||||||
|
expect(block).toContain('uses remaining')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies the command to the clipboard', async () => {
|
||||||
|
const writeText = vi.fn(() => Promise.resolve())
|
||||||
|
vi.stubGlobal('navigator', { clipboard: { writeText } })
|
||||||
|
api.post.mockResolvedValue({
|
||||||
|
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||||
|
})
|
||||||
|
const wrapper = await render()
|
||||||
|
await generateButton(wrapper).trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const copy = wrapper.findAll('button').find((button) => button.text() === 'Copy')
|
||||||
|
await copy.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith(COMMAND)
|
||||||
|
expect(wrapper.find('.bootstrap-block').text()).toContain('Copied')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists live tokens and revoke posts to that token', async () => {
|
||||||
|
api.post.mockResolvedValue({ data: { data: { token: MINTED_TOKEN } } })
|
||||||
|
const wrapper = await render([MINTED_TOKEN])
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith(TOKENS_URL)
|
||||||
|
const rows = wrapper.findAll('.bootstrap-block tbody tr')
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0].text()).toContain('sdbb_012345')
|
||||||
|
expect(rows[0].text()).toContain('0 / 25')
|
||||||
|
|
||||||
|
const revoke = wrapper.findAll('button').find((button) => button.text() === 'Revoke')
|
||||||
|
await revoke.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(api.post).toHaveBeenCalledWith(`${TOKENS_URL}/7/revoke`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers no Revoke on a token that is already spent', async () => {
|
||||||
|
const wrapper = await render([
|
||||||
|
{ ...MINTED_TOKEN, state: 'revoked', usecount: 2 }
|
||||||
|
])
|
||||||
|
expect(wrapper.findAll('button').find((button) => button.text() === 'Revoke'))
|
||||||
|
.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show the secret again after a reload', async () => {
|
||||||
|
api.post.mockResolvedValue({
|
||||||
|
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||||
|
})
|
||||||
|
const first = await render()
|
||||||
|
await generateButton(first).trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(first.find('#bootstrap-command').exists()).toBe(true)
|
||||||
|
|
||||||
|
// The reload sees the token in the list read, which carries no secret.
|
||||||
|
const reloaded = await render([MINTED_TOKEN])
|
||||||
|
expect(reloaded.find('#bootstrap-command').exists()).toBe(false)
|
||||||
|
expect(reloaded.text()).not.toContain(SECRET)
|
||||||
|
expect(reloaded.text()).not.toContain('The token is shown once')
|
||||||
|
// The row is still there, by prefix only.
|
||||||
|
expect(reloaded.find('.bootstrap-block tbody tr').text()).toContain('sdbb_012345')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""One-line bootstrap install tokens.
|
||||||
|
|
||||||
|
Revision ID: endpoints0024bootstraptokens
|
||||||
|
Revises: endpoints0023credentials
|
||||||
|
Create Date: 2026-09-15
|
||||||
|
|
||||||
|
Creates endpointbootstraptokens: the short-lived, admin-minted authorizations
|
||||||
|
that let a tech fetch the bootstrap script and the keyless agent package with a
|
||||||
|
single command. Only the sha256 of the secret is stored.
|
||||||
|
|
||||||
|
Guarded so the revision is safe on a database built by create_all and on a
|
||||||
|
re-run. MySQL 5.6 safe: no JSON columns, and the only index is the unique key
|
||||||
|
on a fixed-length CHAR(64), well under the 767-byte limit on utf8mb4.
|
||||||
|
|
||||||
|
Downgrade drops the table.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = 'endpoints0024bootstraptokens'
|
||||||
|
down_revision = 'endpoints0023credentials'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
plugin_name = 'endpoints'
|
||||||
|
|
||||||
|
TOKENS = 'endpointbootstraptokens'
|
||||||
|
|
||||||
|
|
||||||
|
def _inspector():
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
return inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(name):
|
||||||
|
return _inspector().has_table(name)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
if _has_table(TOKENS):
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
TOKENS,
|
||||||
|
sa.Column('tokenid', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('tokenhash', sa.CHAR(length=64), nullable=False),
|
||||||
|
sa.Column('tokenprefix', sa.String(length=12), nullable=False),
|
||||||
|
sa.Column('createdat', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('createdby', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('expiresat', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('maxuses', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('usecount', sa.Integer(), nullable=False,
|
||||||
|
server_default='0'),
|
||||||
|
sa.Column('revokedat', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('lastusedat', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('lastusedip', sa.String(length=45), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('tokenid'),
|
||||||
|
sa.UniqueConstraint('tokenhash'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
if _has_table(TOKENS):
|
||||||
|
op.drop_table(TOKENS)
|
||||||
@@ -9,6 +9,7 @@ from .operatorreport import OperatorReport
|
|||||||
from .credential import (
|
from .credential import (
|
||||||
EndpointCredential, EndpointEnrollRequest, EndpointMigrationWave,
|
EndpointCredential, EndpointEnrollRequest, EndpointMigrationWave,
|
||||||
)
|
)
|
||||||
|
from .bootstrap import EndpointBootstrapToken
|
||||||
from .health import (
|
from .health import (
|
||||||
EndpointHealthState, EndpointHealthTransition, EndpointMonitorImpact,
|
EndpointHealthState, EndpointHealthTransition, EndpointMonitorImpact,
|
||||||
)
|
)
|
||||||
@@ -18,5 +19,6 @@ __all__ = [
|
|||||||
'EndpointLock', 'EndpointLogCollection',
|
'EndpointLock', 'EndpointLogCollection',
|
||||||
'EndpointMonitor', 'OperatorReport',
|
'EndpointMonitor', 'OperatorReport',
|
||||||
'EndpointCredential', 'EndpointEnrollRequest', 'EndpointMigrationWave',
|
'EndpointCredential', 'EndpointEnrollRequest', 'EndpointMigrationWave',
|
||||||
|
'EndpointBootstrapToken',
|
||||||
'EndpointHealthState', 'EndpointHealthTransition', 'EndpointMonitorImpact',
|
'EndpointHealthState', 'EndpointHealthTransition', 'EndpointMonitorImpact',
|
||||||
]
|
]
|
||||||
|
|||||||
78
plugins/endpoints/models/bootstrap.py
Normal file
78
plugins/endpoints/models/bootstrap.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""One-line bootstrap install tokens.
|
||||||
|
|
||||||
|
An admin mints a short-lived token in the UI and hands a tech one command. The
|
||||||
|
token is only ever an authorization to FETCH the bootstrap script and the
|
||||||
|
keyless agent package: it carries no fleet key, provisions no credential, and
|
||||||
|
the PC it installs still has to be approved in Settings > Endpoints >
|
||||||
|
Enrollment before it can report anything.
|
||||||
|
|
||||||
|
Only the sha256 of the secret is stored, plus a short display prefix, the same
|
||||||
|
shape as EndpointCredential. A token is spent per package download (usecount
|
||||||
|
against maxuses) and dies at expiresat or when an admin revokes it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from shopdb.api import db
|
||||||
|
|
||||||
|
|
||||||
|
def _utciso(value):
|
||||||
|
return value.isoformat() + 'Z' if value else None
|
||||||
|
|
||||||
|
|
||||||
|
class EndpointBootstrapToken(db.Model):
|
||||||
|
"""A short-lived authorization to fetch the bootstrap installer."""
|
||||||
|
|
||||||
|
__tablename__ = 'endpointbootstraptokens'
|
||||||
|
|
||||||
|
tokenid = db.Column(db.Integer, primary_key=True)
|
||||||
|
tokenhash = db.Column(db.CHAR(64), nullable=False, unique=True)
|
||||||
|
tokenprefix = db.Column(db.String(12), nullable=False)
|
||||||
|
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
createdby = db.Column(db.String(64), nullable=True)
|
||||||
|
expiresat = db.Column(db.DateTime, nullable=False)
|
||||||
|
maxuses = db.Column(db.Integer, nullable=False, default=1)
|
||||||
|
usecount = db.Column(db.Integer, nullable=False, default=0,
|
||||||
|
server_default='0')
|
||||||
|
revokedat = db.Column(db.DateTime, nullable=True)
|
||||||
|
lastusedat = db.Column(db.DateTime, nullable=True)
|
||||||
|
lastusedip = db.Column(db.String(45), nullable=True)
|
||||||
|
|
||||||
|
def isusable(self, now):
|
||||||
|
"""Whether this token may still serve a download at `now`."""
|
||||||
|
if self.revokedat is not None:
|
||||||
|
return False
|
||||||
|
if self.expiresat is None or self.expiresat <= now:
|
||||||
|
return False
|
||||||
|
return (self.usecount or 0) < (self.maxuses or 0)
|
||||||
|
|
||||||
|
def state(self, now):
|
||||||
|
if self.revokedat is not None:
|
||||||
|
return 'revoked'
|
||||||
|
if self.expiresat is None or self.expiresat <= now:
|
||||||
|
return 'expired'
|
||||||
|
if (self.usecount or 0) >= (self.maxuses or 0):
|
||||||
|
return 'exhausted'
|
||||||
|
return 'active'
|
||||||
|
|
||||||
|
def to_dict(self, now=None):
|
||||||
|
now = now or datetime.utcnow()
|
||||||
|
used = self.usecount or 0
|
||||||
|
maximum = self.maxuses or 0
|
||||||
|
return {
|
||||||
|
'tokenid': self.tokenid,
|
||||||
|
'tokenprefix': self.tokenprefix,
|
||||||
|
'createdat': _utciso(self.createdat),
|
||||||
|
'createdby': self.createdby,
|
||||||
|
'expiresat': _utciso(self.expiresat),
|
||||||
|
'maxuses': maximum,
|
||||||
|
'usecount': used,
|
||||||
|
'remaining': max(maximum - used, 0),
|
||||||
|
'revokedat': _utciso(self.revokedat),
|
||||||
|
'lastusedat': _utciso(self.lastusedat),
|
||||||
|
'lastusedip': self.lastusedip,
|
||||||
|
'state': self.state(now),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<EndpointBootstrapToken {self.tokenprefix}>'
|
||||||
@@ -60,7 +60,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
|||||||
'endpointhealthtransitions', 'endpointmonitorimpacts',
|
'endpointhealthtransitions', 'endpointmonitorimpacts',
|
||||||
'endpointlocks', 'endpointlogcollections',
|
'endpointlocks', 'endpointlogcollections',
|
||||||
'endpointcredentials', 'endpointenrollrequests',
|
'endpointcredentials', 'endpointenrollrequests',
|
||||||
'endpointmigrationwaves'),
|
'endpointmigrationwaves', 'endpointbootstraptokens'),
|
||||||
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
|
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
|
||||||
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
||||||
'manifestinusechecks', 'manifestinusecheckprocesses',
|
'manifestinusechecks', 'manifestinusecheckprocesses',
|
||||||
|
|||||||
@@ -134,6 +134,19 @@ PUBLIC_ENDPOINTS = {
|
|||||||
# machine's alerts rather than the fleet's. Authoring a fleet alert is the
|
# machine's alerts rather than the fleet's. Authoring a fleet alert is the
|
||||||
# actual authority and stays gated in the notifications plugin.
|
# actual authority and stays gated in the notifications plugin.
|
||||||
'endpoints.get_fleet_alerts',
|
'endpoints.get_fleet_alerts',
|
||||||
|
|
||||||
|
# The one-line bootstrap installer. A tech pastes one command into an
|
||||||
|
# elevated PowerShell on a PC that has no agent and therefore no credential
|
||||||
|
# and no user session, so neither route can carry a token in a header: the
|
||||||
|
# whole authorization is the short-lived bootstrap token in ?t=, minted by
|
||||||
|
# an admin (role-gated, audited) and revocable. Both 404 on anything not
|
||||||
|
# currently usable so the URL confirms nothing, both are per-IP rate
|
||||||
|
# limited, and both are audited with the caller's IP. What they hand out is
|
||||||
|
# deliberately not an authority: the package NEVER carries the fleet key,
|
||||||
|
# so the PC it installs can only sit in Pending until an admin approves its
|
||||||
|
# enrollment.
|
||||||
|
'endpoints.serve_bootstrap_script',
|
||||||
|
'endpoints.download_bootstrap_package',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Reads that need a LOGIN but no named permission, because the handler decides
|
# Reads that need a LOGIN but no named permission, because the handler decides
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
|||||||
# 0022 are retired no-ops (client certificates, removed). 0023 drops their
|
# 0022 are retired no-ops (client certificates, removed). 0023 drops their
|
||||||
# leftover asset columns, renames app_fingerprint, and adds per-PC credentials:
|
# leftover asset columns, renames app_fingerprint, and adds per-PC credentials:
|
||||||
# endpointcredentials, endpointenrollrequests and endpointmigrationwaves.
|
# endpointcredentials, endpointenrollrequests and endpointmigrationwaves.
|
||||||
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0023credentials'
|
# 0024 adds endpointbootstraptokens: the short-lived, admin-minted tokens behind
|
||||||
|
# the one-line bootstrap installer.
|
||||||
|
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0024bootstraptokens'
|
||||||
|
|
||||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||||
# core chain never owned.
|
# core chain never owned.
|
||||||
|
|||||||
388
tests/test_plugins/test_endpoints_bootstrap_install.py
Normal file
388
tests/test_plugins/test_endpoints_bootstrap_install.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"""One-line bootstrap installer: token lifecycle and the two public downloads.
|
||||||
|
|
||||||
|
The token is the whole authorization on a PC that has no agent, so these tests
|
||||||
|
read the real routes rather than the helpers: that an admin mints and revokes,
|
||||||
|
that anything not currently usable answers 404 rather than confirming itself,
|
||||||
|
that only the package download spends a use, that the package can never carry a
|
||||||
|
collector key, and that every fetch leaves an audit row with the caller's IP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
from plugins.endpoints.api import agentstore, siteagent
|
||||||
|
from plugins.endpoints.models import EndpointBootstrapToken
|
||||||
|
from shopdb.core.models import AuditLog, Setting
|
||||||
|
|
||||||
|
TOKENS_URL = '/api/endpoints/siteagent/bootstrap/tokens'
|
||||||
|
SCRIPT_URL = '/api/endpoints/siteagent/bootstrap.ps1'
|
||||||
|
PACKAGE_URL = '/api/endpoints/siteagent/bootstrap/package'
|
||||||
|
BASE_URL = 'https://shopdb.test/shopdb'
|
||||||
|
|
||||||
|
|
||||||
|
def _make_cert():
|
||||||
|
key = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'server.test')])
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
certificate = (x509.CertificateBuilder()
|
||||||
|
.subject_name(name).issuer_name(name)
|
||||||
|
.public_key(key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - datetime.timedelta(days=1))
|
||||||
|
.not_valid_after(now + datetime.timedelta(days=30))
|
||||||
|
.sign(key, hashes.SHA256()))
|
||||||
|
pem = certificate.public_bytes(serialization.Encoding.PEM)
|
||||||
|
spki = certificate.public_key().public_bytes(
|
||||||
|
serialization.Encoding.DER,
|
||||||
|
serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||||
|
return pem, hashlib.sha256(spki).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolate(tmp_path, monkeypatch, app, db):
|
||||||
|
"""Empty agent store, no auto-detected certificate, a known base URL."""
|
||||||
|
store = tmp_path / 'siteagent'
|
||||||
|
store.mkdir()
|
||||||
|
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(store))
|
||||||
|
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', ())
|
||||||
|
Setting.set('site_base_url', BASE_URL, valuetype='string')
|
||||||
|
# The limiter is process-wide cache state; a noisy neighbour test would
|
||||||
|
# otherwise leak its budget into this module.
|
||||||
|
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED'] = False
|
||||||
|
yield tmp_path
|
||||||
|
app.config.pop('ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED', None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def servercert(tmp_path, monkeypatch):
|
||||||
|
pem, pin = _make_cert()
|
||||||
|
path = tmp_path / 'server.crt'
|
||||||
|
path.write_bytes(pem)
|
||||||
|
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
|
||||||
|
return pin
|
||||||
|
|
||||||
|
|
||||||
|
def _mint(client, auth_headers, **body):
|
||||||
|
return client.post(TOKENS_URL, json=body or {}, headers=auth_headers)
|
||||||
|
|
||||||
|
|
||||||
|
def _secret(client, auth_headers, **body):
|
||||||
|
response = _mint(client, auth_headers, **body)
|
||||||
|
assert response.status_code == 201, response.get_data(as_text=True)
|
||||||
|
return response.get_json()['data']
|
||||||
|
|
||||||
|
|
||||||
|
def _files(response):
|
||||||
|
assert response.status_code == 200, response.get_data(as_text=True)[:400]
|
||||||
|
archive = zipfile.ZipFile(io.BytesIO(response.data))
|
||||||
|
return {name: archive.read(name) for name in archive.namelist()}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_generate_returns_the_secret_and_the_ready_made_command(
|
||||||
|
client, db, auth_headers):
|
||||||
|
data = _secret(client, auth_headers, hours=6, maxuses=3)
|
||||||
|
|
||||||
|
secret = data['secret']
|
||||||
|
assert secret.startswith('sdbb_')
|
||||||
|
assert len(secret) == len('sdbb_') + 43
|
||||||
|
assert data['command'] == (
|
||||||
|
'powershell -ExecutionPolicy Bypass -c "irm '
|
||||||
|
'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1} | iex"'.format(
|
||||||
|
BASE_URL, secret))
|
||||||
|
token = data['token']
|
||||||
|
assert token['tokenprefix'] == secret[:12]
|
||||||
|
assert token['maxuses'] == 3
|
||||||
|
assert token['usecount'] == 0
|
||||||
|
assert token['remaining'] == 3
|
||||||
|
assert token['state'] == 'active'
|
||||||
|
assert token['createdby'] == 'testadmin'
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_are_a_day_and_twenty_five_uses(client, db, auth_headers):
|
||||||
|
token = _secret(client, auth_headers)['token']
|
||||||
|
assert token['maxuses'] == 25
|
||||||
|
created = datetime.datetime.fromisoformat(token['createdat'][:-1])
|
||||||
|
expires = datetime.datetime.fromisoformat(token['expiresat'][:-1])
|
||||||
|
assert round((expires - created).total_seconds() / 3600) == 24
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('body', [
|
||||||
|
{'hours': 0}, {'hours': 169}, {'maxuses': 0}, {'maxuses': 501},
|
||||||
|
{'hours': 'soon'},
|
||||||
|
])
|
||||||
|
def test_out_of_range_inputs_are_refused(client, db, auth_headers, body):
|
||||||
|
assert _mint(client, auth_headers, **body).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_never_carries_a_secret(client, db, auth_headers):
|
||||||
|
data = _secret(client, auth_headers)
|
||||||
|
response = client.get(TOKENS_URL, headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
tokens = response.get_json()['data']['tokens']
|
||||||
|
assert len(tokens) == 1
|
||||||
|
assert tokens[0]['tokenprefix'] == data['secret'][:12]
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
assert data['secret'] not in body
|
||||||
|
assert 'secret' not in tokens[0]
|
||||||
|
assert 'tokenhash' not in tokens[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_kills_the_token(client, db, auth_headers):
|
||||||
|
data = _secret(client, auth_headers)
|
||||||
|
tokenid = data['token']['tokenid']
|
||||||
|
|
||||||
|
response = client.post('{0}/{1}/revoke'.format(TOKENS_URL, tokenid),
|
||||||
|
headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()['data']['token']['state'] == 'revoked'
|
||||||
|
# Idempotent: revoking twice is not an error.
|
||||||
|
assert client.post('{0}/{1}/revoke'.format(TOKENS_URL, tokenid),
|
||||||
|
headers=auth_headers).status_code == 200
|
||||||
|
assert client.post('{0}/999/revoke'.format(TOKENS_URL),
|
||||||
|
headers=auth_headers).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('method,path', [
|
||||||
|
('post', TOKENS_URL),
|
||||||
|
('get', TOKENS_URL),
|
||||||
|
('post', TOKENS_URL + '/1/revoke'),
|
||||||
|
])
|
||||||
|
def test_only_an_admin_can_manage_tokens(client, db, member_headers, method,
|
||||||
|
path):
|
||||||
|
anonymous = getattr(client, method)(path)
|
||||||
|
assert anonymous.status_code in (401, 422)
|
||||||
|
member = getattr(client, method)(path, headers=member_headers)
|
||||||
|
assert member.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_and_revoke_write_audit_rows(client, db, auth_headers):
|
||||||
|
data = _secret(client, auth_headers)
|
||||||
|
client.post('{0}/{1}/revoke'.format(TOKENS_URL, data['token']['tokenid']),
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
rows = AuditLog.query.filter_by(
|
||||||
|
entitytype='EndpointBootstrapToken').order_by(AuditLog.auditlogid).all()
|
||||||
|
assert [row.action for row in rows] == ['created', 'updated']
|
||||||
|
assert all(row.entityname == data['secret'][:12] for row in rows)
|
||||||
|
assert all(row.username == 'testadmin' for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# bootstrap.ps1
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_script_is_served_for_a_good_token(client, db, auth_headers):
|
||||||
|
secret = _secret(client, auth_headers)['secret']
|
||||||
|
response = client.get(SCRIPT_URL, query_string={'t': secret})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.mimetype == 'text/plain'
|
||||||
|
script = response.get_data(as_text=True)
|
||||||
|
script.encode('ascii')
|
||||||
|
assert '{0}/api/endpoints/siteagent/bootstrap/package?t={1}'.format(
|
||||||
|
BASE_URL, secret) in script
|
||||||
|
assert 'Run as administrator' in script
|
||||||
|
assert 'GE-Enforce' in script
|
||||||
|
assert 'SHOPDB_BOOTSTRAP_FORCE' in script
|
||||||
|
assert 'Settings > Endpoints > Enrollment' in script
|
||||||
|
assert 'approved and enrolled' in script
|
||||||
|
assert 'Get-FileHash' in script
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_bakes_the_staged_binary_hashes(client, db, auth_headers,
|
||||||
|
tmp_path):
|
||||||
|
payload = b'MZ' + b'agent-bytes'
|
||||||
|
traypayload = b'MZ' + b'tray-bytes'
|
||||||
|
agentstore.store_version('agent', '1.20.0', payload)
|
||||||
|
agentstore.store_version('tray', '1.7.0', traypayload)
|
||||||
|
|
||||||
|
secret = _secret(client, auth_headers)['secret']
|
||||||
|
script = client.get(SCRIPT_URL,
|
||||||
|
query_string={'t': secret}).get_data(as_text=True)
|
||||||
|
|
||||||
|
assert hashlib.sha256(payload).hexdigest() in script
|
||||||
|
assert hashlib.sha256(traypayload).hexdigest() in script
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_fetch_does_not_spend_a_use(client, db, auth_headers):
|
||||||
|
secret = _secret(client, auth_headers, maxuses=1)['secret']
|
||||||
|
for _ in range(3):
|
||||||
|
assert client.get(SCRIPT_URL,
|
||||||
|
query_string={'t': secret}).status_code == 200
|
||||||
|
|
||||||
|
row = EndpointBootstrapToken.query.one()
|
||||||
|
assert row.usecount == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_fetch_is_audited_with_the_caller_ip(client, db, auth_headers):
|
||||||
|
secret = _secret(client, auth_headers)['secret']
|
||||||
|
client.get(SCRIPT_URL, query_string={'t': secret},
|
||||||
|
environ_overrides={'REMOTE_ADDR': '10.0.0.9'})
|
||||||
|
|
||||||
|
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
|
||||||
|
action='requested').one()
|
||||||
|
assert row.ipaddress == '10.0.0.9'
|
||||||
|
assert '10.0.0.9' in row.details
|
||||||
|
assert row.username is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The package
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_package_is_served_and_spends_one_use(client, db, auth_headers,
|
||||||
|
servercert):
|
||||||
|
secret = _secret(client, auth_headers, maxuses=2)['secret']
|
||||||
|
files = _files(client.get(PACKAGE_URL, query_string={'t': secret},
|
||||||
|
environ_overrides={'REMOTE_ADDR': '10.0.0.8'}))
|
||||||
|
|
||||||
|
assert 'install.ps1' in files
|
||||||
|
assert 'appsettings.json' in files
|
||||||
|
row = EndpointBootstrapToken.query.one()
|
||||||
|
assert row.usecount == 1
|
||||||
|
assert row.lastusedip == '10.0.0.8'
|
||||||
|
assert row.lastusedat is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_carries_no_collector_key_and_does_carry_server_pins(
|
||||||
|
client, db, auth_headers, servercert):
|
||||||
|
secret = _secret(client, auth_headers)['secret']
|
||||||
|
files = _files(client.get(PACKAGE_URL, query_string={'t': secret}))
|
||||||
|
|
||||||
|
for name, content in files.items():
|
||||||
|
assert b'CollectorKey' not in content, name
|
||||||
|
script = files['install.ps1'].decode('ascii')
|
||||||
|
assert '$sharedKeyMode = "omitted"' in script
|
||||||
|
assert '[string[]] $ServerPins = @("{0}")'.format(servercert) in script
|
||||||
|
settings = json.loads(files['appsettings.json'])
|
||||||
|
assert settings['serverpins'] == [servercert]
|
||||||
|
assert settings['apikey'] == ''
|
||||||
|
assert b'Settings > Endpoints > Enrollment' in files['README.txt']
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_stays_keyless_even_when_the_shared_key_is_allowed(
|
||||||
|
client, db, auth_headers, servercert):
|
||||||
|
Setting.set('plugin.endpoints.sharedkeyallowed', True, valuetype='boolean')
|
||||||
|
secret = _secret(client, auth_headers)['secret']
|
||||||
|
files = _files(client.get(PACKAGE_URL, query_string={'t': secret}))
|
||||||
|
|
||||||
|
assert b'CollectorKey' not in files['install.ps1']
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_download_is_audited(client, db, auth_headers):
|
||||||
|
secret = _secret(client, auth_headers)['secret']
|
||||||
|
client.get(PACKAGE_URL, query_string={'t': secret},
|
||||||
|
environ_overrides={'REMOTE_ADDR': '10.0.0.7'})
|
||||||
|
|
||||||
|
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
|
||||||
|
action='downloaded').one()
|
||||||
|
assert row.ipaddress == '10.0.0.7'
|
||||||
|
assert 'use 1 of' in row.details
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_token_is_refused_once_exhausted(client, db, auth_headers):
|
||||||
|
secret = _secret(client, auth_headers, maxuses=1)['secret']
|
||||||
|
assert client.get(PACKAGE_URL,
|
||||||
|
query_string={'t': secret}).status_code == 200
|
||||||
|
assert client.get(PACKAGE_URL,
|
||||||
|
query_string={'t': secret}).status_code == 404
|
||||||
|
# The script route is gated on the same usability test.
|
||||||
|
assert client.get(SCRIPT_URL,
|
||||||
|
query_string={'t': secret}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Everything unusable answers 404
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||||
|
@pytest.mark.parametrize('bad', ['', 'nonsense', 'sdbb_' + 'a' * 43,
|
||||||
|
'sdbb_short'])
|
||||||
|
def test_an_unknown_token_is_not_found(client, db, url, bad):
|
||||||
|
assert client.get(url, query_string={'t': bad}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||||
|
def test_no_token_at_all_is_not_found(client, db, url):
|
||||||
|
assert client.get(url).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||||
|
def test_an_expired_token_is_not_found(client, db, auth_headers, url):
|
||||||
|
secret = _secret(client, auth_headers, hours=1)['secret']
|
||||||
|
row = EndpointBootstrapToken.query.one()
|
||||||
|
row.expiresat = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
assert client.get(url, query_string={'t': secret}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||||
|
def test_a_revoked_token_is_not_found(client, db, auth_headers, url):
|
||||||
|
data = _secret(client, auth_headers)
|
||||||
|
client.post('{0}/{1}/revoke'.format(TOKENS_URL, data['token']['tokenid']),
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
assert client.get(url, query_string={'t': data['secret']}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bad_token_leaves_no_audit_row(client, db):
|
||||||
|
client.get(SCRIPT_URL, query_string={'t': 'sdbb_' + 'a' * 43})
|
||||||
|
assert AuditLog.query.filter_by(
|
||||||
|
entitytype='EndpointBootstrapToken').count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rate limiting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_the_rate_limit_triggers_on_both_routes(client, db, app, auth_headers):
|
||||||
|
secret = _secret(client, auth_headers, maxuses=500)['secret']
|
||||||
|
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED'] = True
|
||||||
|
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX'] = 3
|
||||||
|
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS'] = 60
|
||||||
|
caller = {'REMOTE_ADDR': '10.9.9.9'}
|
||||||
|
try:
|
||||||
|
codes = [client.get(SCRIPT_URL, query_string={'t': secret},
|
||||||
|
environ_overrides=caller).status_code
|
||||||
|
for _ in range(4)]
|
||||||
|
assert codes == [200, 200, 200, 429]
|
||||||
|
# The budget is per IP, not per route: the package route is already over.
|
||||||
|
limited = client.get(PACKAGE_URL, query_string={'t': secret},
|
||||||
|
environ_overrides=caller)
|
||||||
|
assert limited.status_code == 429
|
||||||
|
assert limited.get_json()['data']['error']['code'] == 'RATE_LIMITED'
|
||||||
|
# A different caller is unaffected.
|
||||||
|
assert client.get(SCRIPT_URL, query_string={'t': secret},
|
||||||
|
environ_overrides={'REMOTE_ADDR': '10.9.9.10'}
|
||||||
|
).status_code == 200
|
||||||
|
finally:
|
||||||
|
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED'] = False
|
||||||
|
app.config.pop('ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX', None)
|
||||||
|
app.config.pop('ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS', None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Base URL resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_the_command_falls_back_to_the_request_when_no_site_url_is_set(
|
||||||
|
client, db, auth_headers):
|
||||||
|
Setting.set('site_base_url', '', valuetype='string')
|
||||||
|
data = _secret(client, auth_headers)
|
||||||
|
assert data['command'].startswith(
|
||||||
|
'powershell -ExecutionPolicy Bypass -c "irm https://localhost'
|
||||||
|
'/api/endpoints/siteagent/bootstrap.ps1?t=')
|
||||||
Reference in New Issue
Block a user