Merge remote-tracking branch 'origin/wj' into wilm
This commit is contained in:
@@ -791,27 +791,62 @@ 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 "try { Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1?t=<token>' -OutFile 'C:\Windows\Temp\shopdb-bootstrap.ps1'; & 'C:\Windows\Temp\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Windows\Temp\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
|
||||
powershell -ExecutionPolicy Bypass -c "try { New-Item -ItemType Directory -Force -Path 'C:\Program Files\ShopDB\bootstrap' | Out-Null; icacls 'C:\Program Files\ShopDB\bootstrap' /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' | Out-Null; if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) { Write-Host 'Refusing to run: could not restrict C:\Program Files\ShopDB\bootstrap to administrators (icacls failed). Fix the directory or delete it, then run the command again.' -ForegroundColor Red; exit 6 }; Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1' -Headers @{'X-Endpoint-Bootstrap-Token'='<token>'} -OutFile 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1'; if ((Get-Acl 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1').GetOwner([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-32-544','S-1-5-18',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) { Write-Host 'Refusing to run: the staged bootstrap script is not owned by an administrator.' -ForegroundColor Red; exit 5 }; & 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
|
||||
```
|
||||
|
||||
Download then run, never `irm ... | iex`: Microsoft Defender scores the
|
||||
piped-to-iex shape as `Trojan:Win32/Commando.A!ml` and kills the process before
|
||||
a line of it runs (reproduced 5 of 5 on a default install, no ASR rules), and
|
||||
every bay runs Defender. The shape of the line is load-bearing too:
|
||||
every bay runs Defender. The rest of the shape is load-bearing too:
|
||||
|
||||
- a literal temp path and no `$` anywhere, because pasting into an
|
||||
already-running PowerShell would otherwise expand `$p` and `$env:TEMP` in the
|
||||
PARENT and hand the child a broken line - as written it means the same in
|
||||
cmd.exe and in PowerShell;
|
||||
- the token rides in the `X-Endpoint-Bootstrap-Token` HEADER. A query string
|
||||
lands verbatim in the IIS and waitress access logs, and a live install token
|
||||
has no business sitting in a log file;
|
||||
- staging is `C:\Program Files\ShopDB\bootstrap`, **not**
|
||||
`C:\Windows\Temp`. That directory grants Users create-file and gives CREATOR
|
||||
OWNER full control of what they create, so any standard user on the bay can
|
||||
pre-plant `C:\Windows\Temp\shopdb-bootstrap.ps1` and wait for a tech to
|
||||
paste the command, which then runs the planted content elevated;
|
||||
- the line re-asserts an admin-only DACL on the directory it creates
|
||||
(`icacls /inheritance:r` granting only the well-known SIDs `S-1-5-32-544` and
|
||||
`S-1-5-18`, never group names, which a rename or a localized install would
|
||||
break), and CHECKS that icacls succeeded: it is a native command, so it
|
||||
reports failure only in an exit code and `$ErrorActionPreference` never
|
||||
applies to it. Exit 6 with a red message, rather than downloading into a
|
||||
directory that still inherits whatever the parent grants;
|
||||
- it refuses (exit 5) to run a staged file owned by anyone but an
|
||||
administrator, `SYSTEM`, or the elevated caller, which is what a pre-planted
|
||||
file looks like;
|
||||
- a literal staging path and no `$` anywhere - including the exit-code read,
|
||||
which is `Get-Variable LASTEXITCODE -ValueOnly`. Pasting into an
|
||||
already-running PowerShell would otherwise expand every `$` in the PARENT and
|
||||
hand the child a broken line. As written it means the same in cmd.exe and in
|
||||
PowerShell;
|
||||
- single quotes for every inner literal, so the outer `-c "..."` is never
|
||||
closed early;
|
||||
- `try`/`finally`, so the dropped file is removed even when the script exits
|
||||
non-zero or the download itself fails. The wrapper's own exit code is the
|
||||
removal's, so read the script's printed result rather than `%ERRORLEVEL%`.
|
||||
|
||||
The same URL is offered in the UI as a **Download .ps1** link, for a tech who
|
||||
would rather save the file and run it. Fetching it does not spend a use either
|
||||
way.
|
||||
The same script is offered in the UI as a **Download .ps1** link, for a tech
|
||||
who would rather save the file and run it. That link is the one caller that
|
||||
cannot send a header, so it uses `?t=<token>&via=download`. Fetching the script
|
||||
does not spend a use either way.
|
||||
|
||||
The `?t=` form without `via=download` is accepted ONLY so a bay provisioned
|
||||
before this change - one still running a command pasted when the query form was
|
||||
the published shape - keeps working. It is deprecated: every such fetch logs a
|
||||
warning naming the caller's IP, and that warning is how the rollout finds bays
|
||||
whose command needs re-issuing. Do not publish it, do not paste it into a new
|
||||
bay, and do not build it by hand. A site that has re-issued all of its live
|
||||
commands turns the query form off with
|
||||
`plugin.endpoints.bootstrapquerytoken` = false, which also disables the
|
||||
Download .ps1 link.
|
||||
|
||||
The key is declared in the endpoints plugin's `get_settings_defaults()`, so it
|
||||
seeds as a boolean row, and it is flipped from Settings > Endpoints > Build
|
||||
site agent > One-line install ("Accept the token in the URL"). It is read
|
||||
through the core settings API, not a route of this plugin's own.
|
||||
|
||||
The token is only an authorization to FETCH the installer. The package it
|
||||
serves carries no shared key, whatever `plugin.endpoints.sharedkeyallowed`
|
||||
@@ -822,26 +857,41 @@ Admin routes (admin role, audited):
|
||||
|
||||
- `POST /api/endpoints/siteagent/bootstrap/tokens` `{"hours": 24, "maxuses": 25}`
|
||||
(`hours` 1-168, `maxuses` 1-500). Returns
|
||||
`{"secret", "command", "scripturl", "token"}`; `scripturl` is the same token
|
||||
URL the command downloads, for the UI's Download .ps1 link.
|
||||
The secret is in the response exactly once; only its sha256 and a 12-character
|
||||
`tokenprefix` are stored.
|
||||
`{"secret", "command", "scripturl", "token"}`. `command` carries the token in
|
||||
the header; `scripturl` is the same route for the UI's Download .ps1 link, in
|
||||
the `?t=<secret>&via=download` form a browser link can actually use. The
|
||||
`via=download` marker is what keeps an admin's own click out of the
|
||||
un-migrated-bay warning count - it is a hint, not authorization, and nothing
|
||||
is granted by it; what matters is that a command generated before this change
|
||||
cannot carry it. 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:
|
||||
Token-authenticated routes. No login; the token is the whole authorization,
|
||||
because a PC with no agent has neither a credential nor a user session. It
|
||||
travels in the `X-Endpoint-Bootstrap-Token` 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 |
|
||||
| `GET /api/endpoints/siteagent/bootstrap.ps1` | no | `text/plain` PowerShell, generated per token |
|
||||
| `GET /api/endpoints/siteagent/bootstrap/package` | yes | the keyless agent package ZIP |
|
||||
|
||||
Both also accept `?t=<secret>` while
|
||||
`plugin.endpoints.bootstrapquerytoken` is true (the default), for bays
|
||||
provisioned before the header form and for the UI Download .ps1 link. A query
|
||||
token without `via=download` logs a deprecation warning against the caller's
|
||||
IP; the Download link's own fetches log at info instead and are audited as
|
||||
`(query-string token, admin download link)`, so the warning count stays a list
|
||||
of bays to re-issue. With the setting false, a query token answers **400**
|
||||
naming the header.
|
||||
|
||||
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
|
||||
exhausted, and for a request that presents no token at all - so the URL
|
||||
confirms nothing about which tokens exist, and a bare probe cannot learn
|
||||
whether this site still accepts the query form. 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
|
||||
@@ -851,7 +901,9 @@ retried paste must not burn the download budget.
|
||||
The generated script:
|
||||
|
||||
- refuses to run unelevated (exit 1) and says to reopen PowerShell as
|
||||
administrator;
|
||||
administrator (the wrapper line has already refused with exit 6 if the
|
||||
staging directory could not be locked down, or exit 5 if the staged file was
|
||||
owned by someone else);
|
||||
- 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;
|
||||
|
||||
@@ -9,9 +9,15 @@ and, when the route names a PC, binds the caller through bind_hostname().
|
||||
X-API-Key The shared fleet key (or a collector-scoped managed
|
||||
token). Accepted only while
|
||||
plugin.endpoints.sharedkeyallowed is on, and never for a
|
||||
PC that holds an active credential. A PC blocked
|
||||
from re-enrolling has no active credential, so an
|
||||
older agent on it keeps working on the shared key.
|
||||
PC that holds an active credential. A PC whose
|
||||
credential was REVOKED falls back to the shared key,
|
||||
which is how a rollback to an older agent works and how
|
||||
the un-migrated fleet keeps reporting; a site that has
|
||||
finished migrating turns
|
||||
plugin.endpoints.revokeddeniessharedkey on and a revoked
|
||||
PC is then refused until it enrolls again. A PC that
|
||||
never enrolled has no credential row at all and is
|
||||
never touched by either rule.
|
||||
|
||||
Only the sha256 of a credential is stored. The full secret exists once, in the
|
||||
enroll response.
|
||||
@@ -37,6 +43,12 @@ DISPLAY_PREFIX_LENGTH = 12
|
||||
|
||||
SHARED_KEY_SETTING = 'plugin.endpoints.sharedkeyallowed'
|
||||
AUTO_ENROLL_SETTING = 'plugin.endpoints.autoenroll'
|
||||
# Off by default, which is what this site does today: revoking a credential
|
||||
# drops the PC back to the shared key. Turning it on makes a revoke a real
|
||||
# eviction - the PC reports nothing until it enrolls again - so it is only safe
|
||||
# once the fleet is off the shared key, and it also closes the rollback path
|
||||
# that revoke + blockreenroll exists for.
|
||||
REVOKED_DENIES_SHARED_KEY_SETTING = 'plugin.endpoints.revokeddeniessharedkey'
|
||||
|
||||
CODE_CREDENTIAL_INVALID = 'CREDENTIAL_INVALID'
|
||||
CODE_SHARED_KEY_REFUSED = 'SHARED_KEY_REFUSED'
|
||||
@@ -70,6 +82,12 @@ def auto_enroll_enabled():
|
||||
return Setting.get(AUTO_ENROLL_SETTING, default=True) is not False
|
||||
|
||||
|
||||
def revoked_denies_shared_key():
|
||||
"""True when a revoked credential also bars the PC from the shared key."""
|
||||
return Setting.get(REVOKED_DENIES_SHARED_KEY_SETTING,
|
||||
default=False) is True
|
||||
|
||||
|
||||
def active_credential_for(assetid):
|
||||
from ..models import EndpointCredential
|
||||
|
||||
@@ -81,6 +99,33 @@ def active_credential_for(assetid):
|
||||
.first())
|
||||
|
||||
|
||||
def shared_key_denied_for(assetid):
|
||||
"""True when this asset may not report on the shared key.
|
||||
|
||||
An active credential always denies it: the PC has its own secret and a
|
||||
shared-key call for it is either an older agent or somebody else.
|
||||
|
||||
A revoked credential denies it only while revokeddeniessharedkey is on.
|
||||
Today it does not, on purpose: revoke is also how an admin rotates a
|
||||
credential or rolls a PC back to an agent that only knows the shared key
|
||||
(revoke + blockreenroll), and denying by default would silence that PC
|
||||
instead. A PC that never enrolled has no credential row and is never denied,
|
||||
which is what keeps the un-migrated fleet reporting.
|
||||
"""
|
||||
from ..models import EndpointCredential
|
||||
|
||||
if assetid is None:
|
||||
return False
|
||||
row = (EndpointCredential.query
|
||||
.filter(EndpointCredential.assetid == assetid)
|
||||
.first())
|
||||
if row is None:
|
||||
return False
|
||||
if row.revokedat is None:
|
||||
return True
|
||||
return revoked_denies_shared_key()
|
||||
|
||||
|
||||
def reenroll_blocked(assetid):
|
||||
"""True when an admin blocked this asset from enrolling again."""
|
||||
from ..models import EndpointCredential
|
||||
@@ -130,8 +175,7 @@ def shared_key_refused_for(hostname):
|
||||
"""True when the shared key may not report for this hostname."""
|
||||
if not shared_key_allowed():
|
||||
return True
|
||||
assetid = resolve_hostname(hostname)
|
||||
return assetid is not None and active_credential_for(assetid) is not None
|
||||
return shared_key_denied_for(resolve_hostname(hostname))
|
||||
|
||||
|
||||
def credential_invalid_response():
|
||||
@@ -148,6 +192,20 @@ def shared_key_refused_response():
|
||||
http_code=401)
|
||||
|
||||
|
||||
def shared_key_revoked_response():
|
||||
"""Same code and status; the operator needs to know which refusal it is.
|
||||
|
||||
The PC holds no credential and cannot use the shared key either, so an
|
||||
admin has to let it enroll again. Nothing changes for the agent, which
|
||||
keeps retrying either way.
|
||||
"""
|
||||
return error_response(
|
||||
CODE_SHARED_KEY_REFUSED,
|
||||
'This endpoint had its credential revoked, so the shared key is not '
|
||||
'accepted for it; let it enroll again',
|
||||
http_code=401)
|
||||
|
||||
|
||||
def asset_mismatch_response():
|
||||
return error_response(
|
||||
CODE_ASSET_MISMATCH,
|
||||
@@ -210,7 +268,8 @@ def bind_hostname(hostname):
|
||||
ASSET_MISMATCH, a hostname on no computer record at all is refused with the
|
||||
same code but its own message, and a missing hostname is fine.
|
||||
Shared key: the hostname's asset (None when absent or unknown, for the
|
||||
route to answer); an enrolled PC is refused.
|
||||
route to answer); an enrolled PC is refused, and so is one whose credential
|
||||
was revoked while revokeddeniessharedkey is on.
|
||||
"""
|
||||
hostname = (hostname or '').strip()
|
||||
if is_credential_caller():
|
||||
@@ -225,7 +284,9 @@ def bind_hostname(hostname):
|
||||
if not hostname:
|
||||
return None, None
|
||||
assetid = resolve_hostname(hostname)
|
||||
if assetid is not None and active_credential_for(assetid) is not None:
|
||||
if shared_key_denied_for(assetid):
|
||||
if active_credential_for(assetid) is None:
|
||||
return None, shared_key_revoked_response()
|
||||
return None, shared_key_refused_response()
|
||||
g.agentassetid = assetid
|
||||
return assetid, None
|
||||
|
||||
@@ -14,14 +14,25 @@ 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
|
||||
GET /siteagent/bootstrap.ps1 token only; not a use
|
||||
GET /siteagent/bootstrap/package 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.
|
||||
The two token-authenticated routes take the token in the
|
||||
X-Endpoint-Bootstrap-Token header, and still accept the deprecated ?t= form so
|
||||
a command already pasted on a bay keeps working. They answer 404 for anything
|
||||
not currently usable - unknown, malformed, expired, revoked, exhausted, or no
|
||||
token presented at all - so the URL confirms nothing about which tokens exist,
|
||||
nor about whether the query form is still accepted here. The one 400 is a real
|
||||
query token offered at a site that has turned the query form off.
|
||||
|
||||
The admin UI's Download .ps1 link has to use the query form (a browser link
|
||||
cannot send a header), so its URL carries a marker that tells it apart from a
|
||||
bay still running a command pasted before this change. Only the bay logs the
|
||||
deprecation warning, which is what the rollout counts un-migrated bays with.
|
||||
|
||||
Both routes are per-IP rate limited with the same fixed window shape the login
|
||||
and payload-download limiters use, and both write an audit row carrying the
|
||||
caller's IP.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -60,12 +71,60 @@ LIST_LIMIT = 100
|
||||
SCRIPT_PATH = '/api/endpoints/siteagent/bootstrap.ps1'
|
||||
PACKAGE_PATH = '/api/endpoints/siteagent/bootstrap/package'
|
||||
|
||||
# Where the one-line command drops the script before running it. A literal, not
|
||||
# $env:TEMP: the line has to survive being pasted into an already-running
|
||||
# Where the one-line command stages the script before running it. A literal,
|
||||
# not $env:TEMP: the line has to survive being pasted into an already-running
|
||||
# PowerShell, which expands every $ inside the outer double quotes before the
|
||||
# child process sees them. The install needs elevation anyway, so this path is
|
||||
# writable by definition.
|
||||
COMMAND_SCRIPT_PATH = r'C:\Windows\Temp\shopdb-bootstrap.ps1'
|
||||
# child process sees them.
|
||||
#
|
||||
# NOT C:\Windows\Temp. That directory grants Users create-file and gives
|
||||
# CREATOR OWNER full control of what they create, so any standard user on the
|
||||
# bay can pre-plant this exact path and wait for a tech to paste the command,
|
||||
# which then runs the planted content elevated. Program Files is admin-only on
|
||||
# a stock install, the command re-asserts an admin-only DACL on the directory
|
||||
# it creates, and it refuses to run a staged file owned by anyone else.
|
||||
COMMAND_STAGE_DIR = r'C:\Program Files\ShopDB\bootstrap'
|
||||
COMMAND_SCRIPT_PATH = COMMAND_STAGE_DIR + r'\shopdb-bootstrap.ps1'
|
||||
|
||||
# Well-known SIDs, not names: the bays are domain-joined and a renamed or
|
||||
# localized Administrators group would quietly break both the grant and the
|
||||
# ownership test.
|
||||
ADMINISTRATORS_SID = 'S-1-5-32-544'
|
||||
SYSTEM_SID = 'S-1-5-18'
|
||||
|
||||
# The token the two public routes accept. A header, not ?t=: a query string
|
||||
# lands verbatim in the reverse proxy and gunicorn access logs, and a live
|
||||
# install token has no business sitting in a log file.
|
||||
BOOTSTRAP_TOKEN_HEADER = 'X-Endpoint-Bootstrap-Token'
|
||||
QUERY_TOKEN_ARG = 't'
|
||||
|
||||
# Where the token on this request came from. Kept apart because the rollout
|
||||
# counts un-migrated bays by the deprecation warning SOURCE_QUERY logs: an
|
||||
# admin clicking Download .ps1 in the browser must not land in that count, and
|
||||
# a request carrying no token at all must not answer differently from one
|
||||
# carrying a token nobody issued.
|
||||
SOURCE_HEADER = 'header'
|
||||
SOURCE_QUERY = 'query'
|
||||
SOURCE_UI_LINK = 'uilink'
|
||||
SOURCE_NONE = 'none'
|
||||
|
||||
# The marker on the admin Download .ps1 link. A hint, NOT authentication: a
|
||||
# caller can add it by hand, and nothing is granted by it. What matters is the
|
||||
# other direction - a one-line command generated before this change cannot
|
||||
# carry it, so a request without it really is a bay to go and re-issue.
|
||||
UI_LINK_ARG = 'via'
|
||||
UI_LINK_VALUE = 'download'
|
||||
|
||||
# DEPRECATED, kept on purpose: every command already pasted on a bay, and the
|
||||
# UI's "Download .ps1" link (a browser link cannot send a header), carry the
|
||||
# token in the query string. Accepting it stays the default so a mid-rollout
|
||||
# bay keeps working; a site that has re-issued its live commands turns it off.
|
||||
# Turning it off also disables the download link.
|
||||
#
|
||||
# Declared in the endpoints plugin's get_settings_defaults() (so the row seeds
|
||||
# boolean, not string) and flipped from Settings > Endpoints > Build site agent
|
||||
# > One-line install, "Accept the token in the URL". A switch with no seed and
|
||||
# no control would be inert on an air-gapped box.
|
||||
QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
|
||||
|
||||
# Fixed-window per-IP limiter, same shape and cache extension as the login
|
||||
# limiter in shopdb.core.api.auth and the GE-Enforce payload limiter. A tech
|
||||
@@ -86,6 +145,22 @@ def _naive_utcnow():
|
||||
|
||||
|
||||
def _caller_ip():
|
||||
"""The caller address for the audit rows and the deprecation warning.
|
||||
|
||||
Read the value before trusting it to identify a bay. client_ip() returns
|
||||
remote_addr and nothing else. On the shipped Windows deploy IIS launches
|
||||
waitress on loopback through HttpPlatformHandler, and deploy/windows/
|
||||
web.config ships the X-Forwarded-For rewrite rule COMMENTED OUT - IIS sets
|
||||
no such header on its own, so waitress's --trusted-proxy=127.0.0.1
|
||||
--trusted-proxy-count=1 has nothing to rewrite REMOTE_ADDR from and leaves
|
||||
it as the loopback peer. TRUSTED_PROXY_COUNT also defaults to 0, so
|
||||
ProxyFix is not installed either. Every row and every warning then reads
|
||||
127.0.0.1, and the un-migrated-bay list identifies no bay.
|
||||
|
||||
It is a real address only where the site enabled that rewrite rule (the
|
||||
installer's -ClientIpSource direct), or set TRUSTED_PROXY_COUNT for a
|
||||
front proxy that sets the header itself.
|
||||
"""
|
||||
return client_ip(request) or 'unknown'
|
||||
|
||||
|
||||
@@ -142,11 +217,31 @@ def bootstrap_base_url():
|
||||
return 'https://{0}{1}'.format(host, root)
|
||||
|
||||
|
||||
def _base(baseurl=None):
|
||||
resolved = baseurl if baseurl is not None else bootstrap_base_url()
|
||||
return resolved.rstrip('/')
|
||||
|
||||
|
||||
def bootstrap_script_url(secret, baseurl=None):
|
||||
"""The token URL that serves the script: downloaded by the command, and
|
||||
offered in the UI as a plain file a tech can save and run instead."""
|
||||
base = (baseurl if baseurl is not None else bootstrap_base_url()).rstrip('/')
|
||||
return '{0}{1}?t={2}'.format(base, SCRIPT_PATH, secret)
|
||||
"""The UI's "Download .ps1" link: a plain file a tech can save and run.
|
||||
|
||||
Still the query-string form, because an <a download> cannot set a header.
|
||||
The one-line command no longer uses this shape - see bootstrap_command.
|
||||
|
||||
It carries UI_LINK_ARG so the server can tell this click apart from a bay
|
||||
running a pre-change command. Without that the admin's own Download link
|
||||
fires the deprecation warning the rollout uses to FIND un-migrated bays,
|
||||
and the count becomes noise the moment an admin mints a token.
|
||||
"""
|
||||
return '{0}{1}?{2}={3}&{4}={5}'.format(_base(baseurl), SCRIPT_PATH,
|
||||
QUERY_TOKEN_ARG, secret,
|
||||
UI_LINK_ARG, UI_LINK_VALUE)
|
||||
|
||||
|
||||
def bootstrap_fetch_url(path, baseurl=None):
|
||||
"""A token-free URL for the machine-to-machine fetches. The token rides in
|
||||
BOOTSTRAP_TOKEN_HEADER so it never reaches an access log."""
|
||||
return '{0}{1}'.format(_base(baseurl), path)
|
||||
|
||||
|
||||
def bootstrap_command(secret, baseurl=None):
|
||||
@@ -158,28 +253,145 @@ def bootstrap_command(secret, baseurl=None):
|
||||
|
||||
Single quotes inside and no $ anywhere, so the one line means the same
|
||||
whether it is pasted into cmd.exe or into a PowerShell prompt. The download
|
||||
and the run are wrapped in try/finally so the dropped file goes away even
|
||||
and the run are wrapped in try/finally so the staged file goes away even
|
||||
when the script exits non-zero or the download itself fails.
|
||||
|
||||
Staging is the part worth being careful about, so the line creates
|
||||
COMMAND_STAGE_DIR with an admin-only DACL, clears any leftover file, and
|
||||
refuses (exit 5) to run a staged script that some other principal owns -
|
||||
which is what a pre-planted file looks like. Well-known SIDs throughout,
|
||||
and the owner may also be the elevated caller, since a file created by an
|
||||
administrator is owned by that account rather than by the group.
|
||||
|
||||
The hardening is also CHECKED (exit 6). icacls is a native command, so it
|
||||
reports failure only in an exit code: no exception is raised, and
|
||||
$ErrorActionPreference never applies to it. Unchecked, an icacls that
|
||||
cannot write the DACL leaves the directory inheriting whatever the parent
|
||||
grants and the line downloads into it anyway - the silent version of the
|
||||
hole this staging move was made to close. The same check also covers the
|
||||
New-Item before it, because icacls on a directory that was never created
|
||||
exits non-zero too.
|
||||
|
||||
Read as `Get-Variable LASTEXITCODE -ValueOnly`, not `$LASTEXITCODE`: the
|
||||
whole line must stay free of `$`, or a paste into an already-running
|
||||
PowerShell expands it in the PARENT and the child is handed `-ne 0` with
|
||||
the parent's last exit code baked in.
|
||||
"""
|
||||
return ('powershell -ExecutionPolicy Bypass -c "'
|
||||
"try {{ Invoke-WebRequest -UseBasicParsing '{0}' -OutFile '{1}'; "
|
||||
"& '{1}' }} finally {{ Remove-Item '{1}' -Force "
|
||||
'-ErrorAction SilentlyContinue }}"'
|
||||
).format(bootstrap_script_url(secret, baseurl),
|
||||
COMMAND_SCRIPT_PATH)
|
||||
return (
|
||||
'powershell -ExecutionPolicy Bypass -c "'
|
||||
"try {{ New-Item -ItemType Directory -Force -Path '{stagedir}' "
|
||||
'| Out-Null; '
|
||||
"icacls '{stagedir}' /inheritance:r /grant '*{admin}:(OI)(CI)F' "
|
||||
"'*{system}:(OI)(CI)F' | Out-Null; "
|
||||
'if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) '
|
||||
"{{ Write-Host 'Refusing to run: could not restrict "
|
||||
"{stagedir} to administrators (icacls failed). Fix the directory "
|
||||
"or delete it, then run the command again.' -ForegroundColor Red; "
|
||||
'exit 6 }}; '
|
||||
"Remove-Item '{path}' -Force -ErrorAction SilentlyContinue; "
|
||||
"Invoke-WebRequest -UseBasicParsing '{url}' "
|
||||
"-Headers @{{'{header}'='{secret}'}} -OutFile '{path}'; "
|
||||
"if ((Get-Acl '{path}').GetOwner("
|
||||
'[Security.Principal.SecurityIdentifier]).Value -notin '
|
||||
"@('{admin}','{system}',"
|
||||
'[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) '
|
||||
"{{ Write-Host 'Refusing to run: the staged bootstrap script is not "
|
||||
"owned by an administrator.' -ForegroundColor Red; exit 5 }}; "
|
||||
"& '{path}' }} finally {{ Remove-Item '{path}' -Force "
|
||||
'-ErrorAction SilentlyContinue }}"'
|
||||
).format(stagedir=COMMAND_STAGE_DIR,
|
||||
path=COMMAND_SCRIPT_PATH,
|
||||
url=bootstrap_fetch_url(SCRIPT_PATH, baseurl),
|
||||
header=BOOTSTRAP_TOKEN_HEADER,
|
||||
secret=secret,
|
||||
admin=ADMINISTRATORS_SID,
|
||||
system=SYSTEM_SID)
|
||||
|
||||
|
||||
def _hash_token(secret):
|
||||
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _resolve_token():
|
||||
"""The usable token named by ?t=, else None.
|
||||
def _presented_token():
|
||||
"""(secret, source) for the token this request carries.
|
||||
|
||||
Header first, query string second. The query form is deprecated but still
|
||||
the only one a browser download link can use.
|
||||
|
||||
SOURCE_NONE when nothing was presented, which is NOT the same as an empty
|
||||
query token: reporting a bare GET as 'query' made it answer 400 naming the
|
||||
header, telling an anonymous caller whether this site had turned the query
|
||||
form off, and contradicting the 404-for-everything-unusable rule at the top
|
||||
of this module.
|
||||
"""
|
||||
header = (request.headers.get(BOOTSTRAP_TOKEN_HEADER) or '').strip()
|
||||
if header:
|
||||
return header, SOURCE_HEADER
|
||||
query = (request.args.get(QUERY_TOKEN_ARG) or '').strip()
|
||||
if not query:
|
||||
return '', SOURCE_NONE
|
||||
if request.args.get(UI_LINK_ARG) == UI_LINK_VALUE:
|
||||
return query, SOURCE_UI_LINK
|
||||
return query, SOURCE_QUERY
|
||||
|
||||
|
||||
def _is_query_source(source):
|
||||
"""Both query-string forms, the bay's and the admin download link's. The
|
||||
setting is about a token in an access log, and the admin link puts one
|
||||
there just as surely as a pasted command does."""
|
||||
return source in (SOURCE_QUERY, SOURCE_UI_LINK)
|
||||
|
||||
|
||||
def _query_token_allowed():
|
||||
"""Defaults to True: today's pasted commands and the UI download link both
|
||||
use the query form, and turning that off mid-rollout strands a bay."""
|
||||
return bool(Setting.get(QUERY_TOKEN_SETTING, True))
|
||||
|
||||
|
||||
def _query_token_refused():
|
||||
"""Answered before any lookup, so it confirms nothing about any token."""
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Send the bootstrap token in the {0} header.'.format(
|
||||
BOOTSTRAP_TOKEN_HEADER),
|
||||
http_code=400)
|
||||
|
||||
|
||||
def _note_token_source(source):
|
||||
"""Audit suffix for a token that really resolved, and, for a query-string
|
||||
token, one log line naming the caller and WHICH query path it was.
|
||||
|
||||
Only the bay warns. That warning is the rollout's un-migrated-bay counter
|
||||
(see PER-PC-CREDENTIALS-ROLLOUT.md), so the admin's own Download .ps1 link
|
||||
logs at info instead - otherwise every mint-and-test by an admin adds a
|
||||
bay that does not exist.
|
||||
|
||||
Never the token itself, in either line: keeping it out of the logs is the
|
||||
whole point of the header form.
|
||||
"""
|
||||
if source == SOURCE_UI_LINK:
|
||||
current_app.logger.info(
|
||||
'bootstrap script fetched by %s from the admin Download .ps1 '
|
||||
'link; query-string token, but the admin browser path, NOT an '
|
||||
'un-migrated bay', _caller_ip())
|
||||
return ' (query-string token, admin download link)'
|
||||
if source == SOURCE_QUERY:
|
||||
current_app.logger.warning(
|
||||
'bootstrap token presented in the query string by %s with no '
|
||||
'admin download marker, so this is an un-migrated bay running a '
|
||||
'command pasted before the header form; that form is deprecated '
|
||||
'and slated for removal, re-issue the one-line install command',
|
||||
_caller_ip())
|
||||
return ' (query-string token, deprecated)'
|
||||
return ''
|
||||
|
||||
|
||||
def _resolve_token(secret):
|
||||
"""The usable token with this secret, else None.
|
||||
|
||||
Unknown, malformed, expired, revoked and exhausted all resolve to None so
|
||||
the caller can answer 404 for every one of them.
|
||||
"""
|
||||
secret = (request.args.get('t') or '').strip()
|
||||
if not _TOKEN_PATTERN.match(secret):
|
||||
return None
|
||||
row = EndpointBootstrapToken.query.filter_by(
|
||||
@@ -218,6 +430,7 @@ _SCRIPT_TEMPLATE = '''# ShopDB endpoint agent bootstrap
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$BootstrapPackageUrl = {packageurl}
|
||||
$BootstrapPackageHeaders = @{{ {tokenheader} = {token} }}
|
||||
$BootstrapServiceName = "ShopDbEndpointAgent"
|
||||
$BootstrapInstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent"
|
||||
$BootstrapAgentSha256 = {agentsha}
|
||||
@@ -308,7 +521,7 @@ 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
|
||||
Invoke-WebRequest -Uri $BootstrapPackageUrl -Headers $BootstrapPackageHeaders -OutFile $bootstrapZipPath -UseBasicParsing
|
||||
|
||||
Write-Host "[2/4] Expanding and verifying ..."
|
||||
Expand-Archive -Path $bootstrapZipPath -DestinationPath $bootstrapExtractDir -Force
|
||||
@@ -394,11 +607,12 @@ 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('/')
|
||||
base = _base(baseurl)
|
||||
hashes = siteagent.staged_binary_hashes()
|
||||
return _SCRIPT_TEMPLATE.format(
|
||||
packageurl=json.dumps('{0}{1}?t={2}'.format(base, PACKAGE_PATH,
|
||||
secret)),
|
||||
packageurl=json.dumps(bootstrap_fetch_url(PACKAGE_PATH, base)),
|
||||
tokenheader=json.dumps(BOOTSTRAP_TOKEN_HEADER),
|
||||
token=json.dumps(secret),
|
||||
agentsha=json.dumps(hashes.get('agent', '')),
|
||||
traysha=json.dumps(hashes.get('tray', '')),
|
||||
forcevariable=FORCE_VARIABLE,
|
||||
@@ -500,7 +714,8 @@ def revoke_bootstrap_token(tokenid):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token-authenticated: no login, the token in ?t= is the whole authorization
|
||||
# Token-authenticated: no login, the token is the whole authorization. It
|
||||
# rides in the X-Endpoint-Bootstrap-Token header; ?t= is still accepted.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@endpoints_bp.route('/siteagent/bootstrap.ps1', methods=['GET'])
|
||||
@@ -516,12 +731,15 @@ def serve_bootstrap_script():
|
||||
"""
|
||||
if _ratelimited():
|
||||
return _rate_limit_response()
|
||||
row = _resolve_token()
|
||||
secret, source = _presented_token()
|
||||
if _is_query_source(source) and not _query_token_allowed():
|
||||
return _query_token_refused()
|
||||
row = _resolve_token(secret)
|
||||
if row is None:
|
||||
return _not_found()
|
||||
|
||||
secret = (request.args.get('t') or '').strip()
|
||||
_audit('requested', row, 'bootstrap script served')
|
||||
_audit('requested', row,
|
||||
'bootstrap script served' + _note_token_source(source))
|
||||
db.session.commit()
|
||||
|
||||
return Response(build_bootstrap_script(secret), mimetype='text/plain')
|
||||
@@ -536,7 +754,10 @@ def download_bootstrap_package():
|
||||
"""
|
||||
if _ratelimited():
|
||||
return _rate_limit_response()
|
||||
row = _resolve_token()
|
||||
secret, source = _presented_token()
|
||||
if _is_query_source(source) and not _query_token_allowed():
|
||||
return _query_token_refused()
|
||||
row = _resolve_token(secret)
|
||||
if row is None:
|
||||
return _not_found()
|
||||
|
||||
@@ -544,8 +765,8 @@ def download_bootstrap_package():
|
||||
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))
|
||||
_audit('downloaded', row, 'bootstrap package, use {0} of {1}{2}'.format(
|
||||
row.usecount, row.maxuses, _note_token_source(source)))
|
||||
db.session.commit()
|
||||
|
||||
buffer, warning = siteagent.build_keyless_package(bootstrap_base_url())
|
||||
|
||||
@@ -78,6 +78,14 @@ ENROLL_RATELIMIT_WINDOW_SECONDS = 60
|
||||
PENDING_CAP_SETTING = 'plugin.endpoints.maxpendingenrollments'
|
||||
DEFAULT_PENDING_CAP = 500
|
||||
|
||||
# How long a pending request may go untouched before the cap treats it as
|
||||
# abandoned. An agent waiting for approval re-asks about once a minute, so a row
|
||||
# nothing has touched for a week is a PC that was re-imaged, renamed or scrapped,
|
||||
# or a junk row somebody wrote at the open door. At the cap those are dropped to
|
||||
# make room before a PC nobody has seen before is refused, so a queue that filled
|
||||
# with abandoned rows drains itself instead of blocking every new PC on site.
|
||||
PENDING_STALE_AFTER = timedelta(days=7)
|
||||
|
||||
_NONCE_PATTERN = re.compile(r'^[A-Za-z0-9_-]{32,128}$')
|
||||
|
||||
REASON_UNKNOWN_HOSTNAME = 'unknown hostname'
|
||||
@@ -265,6 +273,38 @@ def _pending_queue_is_full():
|
||||
return pending >= cap
|
||||
|
||||
|
||||
def _drop_stale_pending():
|
||||
"""Delete pending requests nothing has touched in PENDING_STALE_AFTER.
|
||||
|
||||
Only PENDING rows: an admin decision (approved, rejected, issued) is never
|
||||
aged out, so a rejection keeps standing however long the PC argues with it.
|
||||
Returns how many rows went, for the caller to decide whether the cap is
|
||||
still reached.
|
||||
"""
|
||||
cutoff = _naive_utcnow() - PENDING_STALE_AFTER
|
||||
stale = (EndpointEnrollRequest.query
|
||||
.filter(EndpointEnrollRequest.status
|
||||
== EndpointEnrollRequest.STATUS_PENDING,
|
||||
EndpointEnrollRequest.lastseenat < cutoff)
|
||||
.order_by(EndpointEnrollRequest.lastseenat.asc())
|
||||
.all())
|
||||
if not stale:
|
||||
return 0
|
||||
for row in stale:
|
||||
db.session.delete(row)
|
||||
current_app.logger.info(
|
||||
'endpoint enroll: aged out %d pending requests untouched for %d days',
|
||||
len(stale), PENDING_STALE_AFTER.days)
|
||||
AuditLog.log(
|
||||
'deleted', 'EndpointEnrollRequest',
|
||||
entityname=stale[0].hostname,
|
||||
details='aged out {0} pending enroll requests untouched for {1} '
|
||||
'days, to make room under the pending cap'.format(
|
||||
len(stale), PENDING_STALE_AFTER.days))
|
||||
db.session.commit()
|
||||
return len(stale)
|
||||
|
||||
|
||||
def _pending_queue_full_response(hostname, serialnumber):
|
||||
"""Refuse a new row, say so plainly in the log and leave an audit trail."""
|
||||
cap = pending_cap()
|
||||
@@ -349,15 +389,42 @@ def enroll_endpoint():
|
||||
|
||||
if row is None:
|
||||
# First contact for this hostname and serial. This is the only branch
|
||||
# that grows the table, so it is the only one the cap guards.
|
||||
# that grows the table, so it is the only one the cap guards. Every
|
||||
# other contact reuses the one row this identity already owns, which is
|
||||
# what keeps a single PC from holding a queue's worth of them.
|
||||
if _pending_queue_is_full():
|
||||
return _pending_queue_full_response(hostname, serialnumber)
|
||||
# Abandoned rows come out before a real PC is turned away.
|
||||
_drop_stale_pending()
|
||||
if _pending_queue_is_full():
|
||||
return _pending_queue_full_response(hostname, serialnumber)
|
||||
row = EndpointEnrollRequest(
|
||||
hostname=hostname, serialnumber=serialnumber, enrollnonce=nonce,
|
||||
status=EndpointEnrollRequest.STATUS_PENDING, requestedat=now)
|
||||
db.session.add(row)
|
||||
else:
|
||||
samenonce = hmac.compare_digest(row.enrollnonce, nonce)
|
||||
if row.status == EndpointEnrollRequest.STATUS_REJECTED:
|
||||
# A rejection binds to the IDENTITY - this hostname and serial - not
|
||||
# to the nonce it was rejected with. A new installation on the same
|
||||
# PC brings a new nonce, and re-arming the row on it would put the
|
||||
# request back in the queue and, with the shared key and auto-enroll
|
||||
# on, hand the retry the credential an admin had just refused.
|
||||
# Nothing the PC sends clears a rejection; an admin approving the
|
||||
# request does.
|
||||
#
|
||||
# The nonce is still recorded, because a rejected row hands nothing
|
||||
# out and an admin who does approve it later has to bind that
|
||||
# approval to the installation asking NOW, not to the one that was
|
||||
# refused weeks ago.
|
||||
row.enrollnonce = nonce
|
||||
row.lastseenat = now
|
||||
row.sourceip = sourceip
|
||||
if agentversion:
|
||||
row.agentversion = agentversion
|
||||
db.session.commit()
|
||||
return success_response(
|
||||
{'status': 'pending', 'requestid': row.requestid,
|
||||
'reason': REASON_REJECTED}, http_code=202)
|
||||
if row.status == EndpointEnrollRequest.STATUS_APPROVED:
|
||||
row.lastseenat = now
|
||||
row.sourceip = sourceip
|
||||
@@ -380,16 +447,10 @@ def enroll_endpoint():
|
||||
row.lastseenat = now
|
||||
row.sourceip = sourceip
|
||||
return _pending(row, REASON_NONCE_CHANGED, row.matchedassetid)
|
||||
if row.status == EndpointEnrollRequest.STATUS_REJECTED and samenonce:
|
||||
row.lastseenat = now
|
||||
row.sourceip = sourceip
|
||||
db.session.commit()
|
||||
return success_response(
|
||||
{'status': 'pending', 'requestid': row.requestid,
|
||||
'reason': REASON_REJECTED}, http_code=202)
|
||||
if row.status != EndpointEnrollRequest.STATUS_PENDING:
|
||||
# Issued earlier (credential since revoked) or rejected for another
|
||||
# installation: a new enrollment cycle bound to this nonce.
|
||||
# Issued earlier and the credential has since been revoked: a new
|
||||
# enrollment cycle bound to this nonce. Rejected rows never reach
|
||||
# here - they returned above, whatever nonce they carried.
|
||||
row.enrollnonce = nonce
|
||||
row.requestedat = now
|
||||
row.decidedat = None
|
||||
@@ -574,6 +635,7 @@ def _page(rows):
|
||||
def _settings_payload():
|
||||
return {'sharedkeyallowed': agentauth.shared_key_allowed(),
|
||||
'autoenroll': agentauth.auto_enroll_enabled(),
|
||||
'revokeddeniessharedkey': agentauth.revoked_denies_shared_key(),
|
||||
'maxpendingenrollments': pending_cap()}
|
||||
|
||||
|
||||
@@ -667,6 +729,11 @@ def approve_enroll_request(requestid):
|
||||
The pending reason for a one-off PC is by definition `unknown hostname`, so
|
||||
this is the normal case, not the exception.
|
||||
|
||||
Approving is also the only way out of a rejection: a rejected request keeps
|
||||
refusing its PC whatever nonce it sends, and this is the deliberate admin
|
||||
step that lifts it. The approval binds to the nonce of that PC's most recent
|
||||
contact, so an agent reinstalled while it was refused still collects it.
|
||||
|
||||
Body: {"assetid": 123, "sethostname": false}. A computer with no hostname
|
||||
is stamped with the request's. A computer carrying a DIFFERENT hostname is
|
||||
refused with 409 naming both, until the caller repeats the approval with
|
||||
@@ -734,6 +801,7 @@ def approve_enroll_request(requestid):
|
||||
details='hostname {0} from enroll request {1}'.format(
|
||||
hostnameresult, row.requestid))
|
||||
|
||||
wasrejected = row.status == EndpointEnrollRequest.STATUS_REJECTED
|
||||
row.status = EndpointEnrollRequest.STATUS_APPROVED
|
||||
row.matchedassetid = assetid
|
||||
row.reason = None
|
||||
@@ -741,8 +809,9 @@ def approve_enroll_request(requestid):
|
||||
row.decidedby = _username()
|
||||
AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid,
|
||||
entityname=row.hostname,
|
||||
details='enroll request approved for asset {0}, hostname {1}'
|
||||
.format(assetid, hostnameresult))
|
||||
details='enroll request {0} for asset {1}, hostname {2}'
|
||||
.format('approved out of a rejection' if wasrejected
|
||||
else 'approved', assetid, hostnameresult))
|
||||
db.session.commit()
|
||||
approved = row.to_dict()
|
||||
approved['hostnameresult'] = hostnameresult
|
||||
@@ -780,6 +849,11 @@ def revoke_endpoint_credential(assetid):
|
||||
before rolling a PC back to an agent that only knows the shared key: without
|
||||
it the current agent re-enrolls within seconds and the older agent is then
|
||||
refused. A PC whose credential is already revoked can still be blocked.
|
||||
|
||||
Whether a revoked PC may still report on the shared key is the site setting
|
||||
plugin.endpoints.revokeddeniessharedkey. It is off here, so today it can -
|
||||
which is what makes the rollback above work, and what keeps a rotation from
|
||||
silencing the PC while it waits for approval.
|
||||
"""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if not isinstance(payload, dict):
|
||||
@@ -853,12 +927,24 @@ def allow_endpoint_reenroll(assetid):
|
||||
@endpoints_bp.route('/enrollment/settings', methods=['PUT'])
|
||||
@require_role('admin')
|
||||
def update_enrollment_settings():
|
||||
"""Change the enrollment settings.
|
||||
|
||||
revokeddeniessharedkey is off by default, which is what the fleet does
|
||||
today: a PC whose credential was revoked falls back to the shared key, so a
|
||||
credential rotation, and the rollback to an older agent that revoke +
|
||||
blockreenroll exists for, both keep reporting. Turning it on makes a revoke
|
||||
an eviction - that PC reports nothing until it is allowed to enroll again -
|
||||
and is only safe once every PC here holds its own credential.
|
||||
"""
|
||||
from shopdb.api import Setting
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
changes = {}
|
||||
for key, settingkey in (('sharedkeyallowed', agentauth.SHARED_KEY_SETTING),
|
||||
('autoenroll', agentauth.AUTO_ENROLL_SETTING)):
|
||||
for key, settingkey in (
|
||||
('sharedkeyallowed', agentauth.SHARED_KEY_SETTING),
|
||||
('autoenroll', agentauth.AUTO_ENROLL_SETTING),
|
||||
('revokeddeniessharedkey',
|
||||
agentauth.REVOKED_DENIES_SHARED_KEY_SETTING)):
|
||||
if key not in payload:
|
||||
continue
|
||||
if not isinstance(payload[key], bool):
|
||||
@@ -878,7 +964,8 @@ def update_enrollment_settings():
|
||||
if not changes:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Send sharedkeyallowed, autoenroll and/or maxpendingenrollments')
|
||||
'Send sharedkeyallowed, autoenroll, revokeddeniessharedkey '
|
||||
'and/or maxpendingenrollments')
|
||||
|
||||
before = _settings_payload()
|
||||
for key, (settingkey, value, valuetype) in changes.items():
|
||||
|
||||
@@ -410,6 +410,57 @@ function Set-HklmValueBothViews {{
|
||||
}}
|
||||
}}
|
||||
|
||||
# Lock SOFTWARE\\GE\\ShopDB to SYSTEM and Administrators, in both views.
|
||||
#
|
||||
# WHY. The shared fleet key is written here and stays until every consumer is
|
||||
# on a per-PC credential, and the DACL this key inherits by default grants
|
||||
# BUILTIN\\Users read - so any logged-on user on any bay could read the fleet
|
||||
# key, which is the exact exposure the per-PC credential migration exists to
|
||||
# close. Everything that legitimately reads this key runs as SYSTEM (the agent
|
||||
# service, the GE-Enforce reporters) or as an admin (the adoption tool,
|
||||
# Set-ShopDBServerPins.ps1). The tray reads no registry at all - it talks to the
|
||||
# service over the named pipe - so nothing loses access here.
|
||||
#
|
||||
# It also protects ServerPins from a standard user, which is what makes the
|
||||
# registry safe to treat as authoritative for pinning.
|
||||
function Protect-HklmShopdbKey {{
|
||||
param([Parameter(Mandatory)] [string] $SubKey)
|
||||
$rights = [System.Security.AccessControl.RegistryRights]
|
||||
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
|
||||
[Microsoft.Win32.RegistryView]::Registry32)) {{
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
||||
try {{
|
||||
$key = $base.OpenSubKey(
|
||||
$SubKey,
|
||||
[Microsoft.Win32.RegistryKeyPermissionCheck]::ReadWriteSubTree,
|
||||
$rights::ChangePermissions -bor $rights::ReadPermissions)
|
||||
if (-not $key) {{ continue }}
|
||||
try {{
|
||||
$acl = $key.GetAccessControl(
|
||||
[System.Security.AccessControl.AccessControlSections]::Access)
|
||||
# Break inheritance and DROP the inherited entries rather than
|
||||
# copying them in: copying is what silently preserves the
|
||||
# BUILTIN\\Users read this function exists to remove.
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
foreach ($rule in @($acl.Access)) {{ $null = $acl.RemoveAccessRule($rule) }}
|
||||
foreach ($sid in @("S-1-5-18", "S-1-5-32-544")) {{
|
||||
$who = New-Object System.Security.Principal.SecurityIdentifier($sid)
|
||||
$acl.AddAccessRule((New-Object System.Security.AccessControl.RegistryAccessRule(
|
||||
$who,
|
||||
$rights::FullControl,
|
||||
[System.Security.AccessControl.InheritanceFlags]::ContainerInherit,
|
||||
[System.Security.AccessControl.PropagationFlags]::None,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow)))
|
||||
}}
|
||||
$key.SetAccessControl($acl)
|
||||
}}
|
||||
finally {{ $key.Dispose() }}
|
||||
}}
|
||||
finally {{ $base.Dispose() }}
|
||||
}}
|
||||
}}
|
||||
|
||||
# REG_MULTI_SZ in both views, compare-before-write so a reinstall with the same
|
||||
# pins leaves the value (and its last-write time) alone.
|
||||
function Set-HklmMultiStringBothViews {{
|
||||
@@ -492,6 +543,17 @@ else {{
|
||||
Write-Warning "no server pin in this package; ServerPins left as is."
|
||||
}}
|
||||
|
||||
# Harden the key AFTER the writes: the DACL is replaced, not merged, so writing
|
||||
# first and locking second keeps this correct whether or not the key pre-existed.
|
||||
try {{
|
||||
Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"
|
||||
Write-Host " HKLM\\SOFTWARE\\GE\\ShopDB restricted to SYSTEM and Administrators."
|
||||
}}
|
||||
catch {{
|
||||
Write-Warning "could not restrict HKLM\\SOFTWARE\\GE\\ShopDB: $($_.Exception.Message)"
|
||||
Write-Warning "the shared key on this PC stays readable by any logged-on user until that is fixed."
|
||||
}}
|
||||
|
||||
# 1a. Trust the bundled server certificate, if one shipped. The agent's outbound
|
||||
# TLS to a self-signed or internal-CA server fails closed until the machine
|
||||
# trusts the cert. Importing into LocalMachine\\Root covers the SYSTEM service.
|
||||
|
||||
@@ -292,17 +292,63 @@ says so when it finds an agent already installed.
|
||||
a new one if it is lost.
|
||||
3. On the target PC, open PowerShell **as administrator** and paste it:
|
||||
|
||||
**Copy the command the UI gives you.** Do not retype it from here and do not
|
||||
reuse one you saved from an older round - see the warning under this block. It
|
||||
looks like this:
|
||||
|
||||
```
|
||||
powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest -UseBasicParsing 'https://<server>/api/endpoints/siteagent/bootstrap.ps1?t=<token>' -OutFile 'C:\Windows\Temp\shopdb-bootstrap.ps1'; & 'C:\Windows\Temp\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Windows\Temp\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
|
||||
powershell -ExecutionPolicy Bypass -c "try { New-Item -ItemType Directory -Force -Path 'C:\Program Files\ShopDB\bootstrap' | Out-Null; icacls 'C:\Program Files\ShopDB\bootstrap' /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' | Out-Null; if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) { Write-Host 'Refusing to run: could not restrict C:\Program Files\ShopDB\bootstrap to administrators (icacls failed). Fix the directory or delete it, then run the command again.' -ForegroundColor Red; exit 6 }; Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -UseBasicParsing 'https://<server>/api/endpoints/siteagent/bootstrap.ps1' -Headers @{'X-Endpoint-Bootstrap-Token'='<token>'} -OutFile 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1'; if ((Get-Acl 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1').GetOwner([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-32-544','S-1-5-18',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) { Write-Host 'Refusing to run: the staged bootstrap script is not owned by an administrator.' -ForegroundColor Red; exit 5 }; & 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
|
||||
```
|
||||
|
||||
It downloads the script, runs it and deletes it again. Do not shorten it back
|
||||
to `irm ... | iex`: Defender scores that shape as `Trojan:Win32/Commando.A!ml`
|
||||
and kills the process before anything runs, on a default install with no ASR
|
||||
rules. Paste it as one line into an elevated **cmd** or **PowerShell** window;
|
||||
it means the same in both. **Download .ps1** in the same block saves the script
|
||||
instead, for a PC where pasting a long line is awkward - it carries the same
|
||||
token, so treat the file the way you treat the command.
|
||||
It creates an admin-only staging directory, downloads the script into it, runs
|
||||
it and deletes it again. Do not shorten it back to `irm ... | iex`: Defender
|
||||
scores that shape as `Trojan:Win32/Commando.A!ml` and kills the process before
|
||||
anything runs, on a default install with no ASR rules. Paste it as one line
|
||||
into an elevated **cmd** or **PowerShell** window; it means the same in both.
|
||||
**Download .ps1** in the same block saves the script instead, for a PC where
|
||||
pasting a long line is awkward - it carries the same token, so treat the file
|
||||
the way you treat the command.
|
||||
|
||||
> **Do not use the old `?t=<token>` one-liner that staged into
|
||||
> `C:\Windows\Temp`.** Any standard user on the bay can create that exact path
|
||||
> first and wait for a tech to paste the command, which then runs the planted
|
||||
> file elevated; the token also lands in the web server's access log. The
|
||||
> command above stages under `C:\Program Files`, locks the directory to
|
||||
> Administrators and SYSTEM and stops if that fails, refuses to run a staged
|
||||
> file owned by anyone else, and sends the token in a header.
|
||||
>
|
||||
> The `?t=` form is still ACCEPTED, but only so a bay provisioned before this
|
||||
> change keeps working until its command is re-issued. Every such fetch logs a
|
||||
> server warning and writes an audit row against the token:
|
||||
>
|
||||
> ```
|
||||
> bootstrap token presented in the query string by <caller> with no admin
|
||||
> download marker, so this is an un-migrated bay running a command pasted
|
||||
> before the header form; ... re-issue the one-line install command
|
||||
> ```
|
||||
>
|
||||
> **`<caller>` is 127.0.0.1 on a stock Windows install.** IIS runs the app
|
||||
> through HttpPlatformHandler over loopback and sets no `X-Forwarded-For` of
|
||||
> its own; `deploy/windows/web.config` ships the rewrite rule that would add
|
||||
> one commented out, so waitress has nothing to rewrite `REMOTE_ADDR` from.
|
||||
> Check before you rely on the address: if these lines all say 127.0.0.1, the
|
||||
> warning still tells you un-migrated bays EXIST and how many fetches they
|
||||
> made, but not which bays. Get real addresses by enabling that rule (the
|
||||
> Windows installer's `-ClientIpSource direct`, which installs URL Rewrite and
|
||||
> allows `HTTP_X_FORWARDED_FOR` at server level); behind a further reverse
|
||||
> proxy leave it off and set `TRUSTED_PROXY_COUNT` instead. Without that, the
|
||||
> usable identifier is the token: each warning has a matching audit row on
|
||||
> `EndpointBootstrapToken` carrying the 12-character `tokenprefix`, so you can
|
||||
> at least tell which install round the stale command came from.
|
||||
>
|
||||
> Work that list down: mint a new token and hand out the new command. Clicking
|
||||
> **Download .ps1** yourself does NOT add to it - that link is marked
|
||||
> `&via=download` and logs at info, precisely so the warnings stay a list of
|
||||
> bays. Once the list is empty, clear **Accept the token in the URL** in
|
||||
> Settings -> Endpoints -> Build site agent -> One-line install
|
||||
> (`plugin.endpoints.bootstrapquerytoken`) to refuse the query form outright.
|
||||
> That also disables the Download .ps1 link and kills every command still in a
|
||||
> technician's hands, so do it deliberately and re-issue first.
|
||||
|
||||
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
|
||||
@@ -324,6 +370,15 @@ 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.
|
||||
|
||||
If it stops before any of that with a red **Refusing to run**, nothing was
|
||||
installed and nothing was downloaded. Two cases, both meaning look at the bay
|
||||
rather than retry:
|
||||
|
||||
| Exit | Message | What to do |
|
||||
|---|---|---|
|
||||
| 6 | could not restrict `C:\Program Files\ShopDB\bootstrap` to administrators | the ACL could not be set - usually the directory already exists with an owner or DACL you did not put there. Inspect it (`icacls`), delete it, run the command again. |
|
||||
| 5 | the staged bootstrap script is not owned by an administrator | something else wrote the staged file. Treat the bay as suspect, do not force past it. |
|
||||
|
||||
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.
|
||||
|
||||
@@ -482,6 +482,31 @@
|
||||
Settings > Endpoints > Enrollment.
|
||||
</p>
|
||||
|
||||
<!-- The deprecated query form of the bootstrap token. Rendered here
|
||||
because this is the page that mints the command the setting
|
||||
governs: turning it off invalidates every command already handed
|
||||
out that carries ?t=. -->
|
||||
<label class="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle-querytoken"
|
||||
:checked="querytokenallowed"
|
||||
:disabled="querytokenbusy"
|
||||
@change="onQueryTokenToggle($event)"
|
||||
/>
|
||||
<span>
|
||||
<strong>Accept the token in the URL.</strong> On by default:
|
||||
the pasted one-line command and the Download .ps1 link above both
|
||||
send the token as <code>?t=</code>, which is the only form a
|
||||
browser download link can use. The token lands in the IIS and
|
||||
proxy logs that way, so the header form
|
||||
(<code>X-Endpoint-Bootstrap-Token</code>) replaces it. Turn this off
|
||||
and every install command still in a technician's hands stops
|
||||
working immediately - re-issue them first.
|
||||
</span>
|
||||
</label>
|
||||
<p v-if="querytokenerror" class="field-error">{{ querytokenerror }}</p>
|
||||
|
||||
<table v-if="bootstraptokens.length" class="versions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -764,6 +789,15 @@ const bootstraptokens = ref([])
|
||||
const bootstrapmessage = ref('')
|
||||
const bootstraperror = ref('')
|
||||
|
||||
// plugin.endpoints.bootstrapquerytoken, read and written through the core
|
||||
// settings API: the endpoints plugin has no settings route of its own for it,
|
||||
// and the key is declared in get_settings_defaults so the upsert on PUT gets
|
||||
// the boolean valuetype and the description from that declaration.
|
||||
const QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
|
||||
const querytokenallowed = ref(true)
|
||||
const querytokenbusy = ref(false)
|
||||
const querytokenerror = ref('')
|
||||
|
||||
function agentnextpininvalid() {
|
||||
const pin = agentnextserverpin.value
|
||||
return !!pin && !/^[0-9a-f]{64}$/.test(pin)
|
||||
@@ -863,6 +897,7 @@ onMounted(async () => {
|
||||
}
|
||||
loadEnrollment()
|
||||
loadBootstrapTokens()
|
||||
loadQueryTokenSetting()
|
||||
try {
|
||||
const response = await api.get('/endpoints/siteagent')
|
||||
const status = response.data.data || {}
|
||||
@@ -878,6 +913,53 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
async function loadQueryTokenSetting() {
|
||||
try {
|
||||
const response = await api.get(`/settings/${QUERY_TOKEN_SETTING}`)
|
||||
// The backend reads it as bool(Setting.get(key, True)), so only an
|
||||
// explicit false is off. A row seeded as boolean comes back as a real
|
||||
// false; an older row that was created as a string comes back as 'false'.
|
||||
const value = response.data.data?.value
|
||||
querytokenallowed.value = !(value === false || value === 'false')
|
||||
} catch (loadError) {
|
||||
// 404 = the row was never seeded (plugin installed before the key was
|
||||
// declared). The backend default is on, so show on.
|
||||
if (loadError.response?.status !== 404) console.error(loadError)
|
||||
querytokenallowed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function onQueryTokenToggle(event) {
|
||||
const turningOn = event.target.checked
|
||||
if (!turningOn) {
|
||||
const answer = prompt(
|
||||
'Stop accepting the token in the URL?\n\n'
|
||||
+ 'Every install command already handed out uses ?t= and stops '
|
||||
+ 'working at once, including the Download .ps1 links. Re-issue them '
|
||||
+ 'before turning this off.\n\n'
|
||||
+ 'Type OFF to confirm.')
|
||||
if (!answer || answer.trim().toUpperCase() !== 'OFF') {
|
||||
event.target.checked = true
|
||||
return
|
||||
}
|
||||
}
|
||||
querytokenbusy.value = true
|
||||
querytokenerror.value = ''
|
||||
try {
|
||||
// PUT upserts, so this works on a site whose row was never seeded.
|
||||
await api.put(`/settings/${QUERY_TOKEN_SETTING}`, { value: turningOn })
|
||||
querytokenallowed.value = turningOn
|
||||
} catch (saveError) {
|
||||
querytokenerror.value = saveError.response?.status === 403
|
||||
? 'Only an administrator can change this setting'
|
||||
: 'Could not save the setting'
|
||||
event.target.checked = querytokenallowed.value
|
||||
console.error(saveError)
|
||||
} finally {
|
||||
querytokenbusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBootstrapTokens() {
|
||||
try {
|
||||
const response = await api.get('/endpoints/siteagent/bootstrap/tokens')
|
||||
|
||||
@@ -229,6 +229,27 @@
|
||||
on record matches. Anything else waits below for an admin.
|
||||
</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle-revokeddenies"
|
||||
:checked="enrollmentSettings.revokeddeniessharedkey"
|
||||
:disabled="!isadmin || busy"
|
||||
@change="onRevokedDeniesToggle($event)"
|
||||
/>
|
||||
<span>
|
||||
<strong>A revoked PC loses the shared key too.</strong> (setting
|
||||
<code>plugin.endpoints.revokeddeniessharedkey</code>) Off by
|
||||
default, which is what the fleet does today: a PC whose credential
|
||||
was revoked falls back to the shared key and keeps reporting, so a
|
||||
credential rotation does not strand it. Turn this on and a revoke
|
||||
becomes an eviction - that PC reports nothing and receives no
|
||||
commands until it enrolls again - and the documented rollback of
|
||||
revoke plus block re-enrollment onto an older agent that only knows
|
||||
the shared key stops working. Only safe once every PC here is
|
||||
enrolled.
|
||||
</span>
|
||||
</label>
|
||||
<label class="number-row">
|
||||
<span>
|
||||
<strong>Pending request limit.</strong> How many requests may wait
|
||||
@@ -670,6 +691,9 @@ const DEFAULT_PENDING_CAP = 500
|
||||
const enrollmentSettings = ref({
|
||||
sharedkeyallowed: true,
|
||||
autoenroll: true,
|
||||
// Matches the seeded default (plugin.endpoints.revokeddeniessharedkey is
|
||||
// false), so the box renders unchecked before the first read lands.
|
||||
revokeddeniessharedkey: false,
|
||||
maxpendingenrollments: DEFAULT_PENDING_CAP
|
||||
})
|
||||
// Bound with v-model rather than :value so the field holds what the operator
|
||||
@@ -697,6 +721,8 @@ async function loadEnrollment({ quiet = false } = {}) {
|
||||
enrollmentSettings.value = {
|
||||
sharedkeyallowed: data.settings?.sharedkeyallowed !== false,
|
||||
autoenroll: data.settings?.autoenroll !== false,
|
||||
// Opt-in, so anything but an explicit true reads as off.
|
||||
revokeddeniessharedkey: data.settings?.revokeddeniessharedkey === true,
|
||||
maxpendingenrollments: Number.isFinite(Number(data.settings?.maxpendingenrollments))
|
||||
? Number(data.settings.maxpendingenrollments)
|
||||
: DEFAULT_PENDING_CAP
|
||||
@@ -771,6 +797,26 @@ function onAutoEnrollToggle(event) {
|
||||
})
|
||||
}
|
||||
|
||||
function onRevokedDeniesToggle(event) {
|
||||
const turningOn = event.target.checked
|
||||
if (turningOn) {
|
||||
// Typed confirm, like the shared-key switch: this one silences PCs that
|
||||
// are reporting right now, and the revoke-plus-rollback recipe in the
|
||||
// runbook stops working the moment it is on.
|
||||
const answer = prompt(
|
||||
'Deny the shared key to revoked PCs?\n\n'
|
||||
+ 'Any PC whose credential was revoked stops reporting and stops '
|
||||
+ 'receiving commands until it enrolls again. The revoke + block '
|
||||
+ 're-enrollment rollback onto an older agent no longer works.\n\n'
|
||||
+ 'Type DENY to confirm.')
|
||||
if (!answer || answer.trim().toUpperCase() !== 'DENY') {
|
||||
event.target.checked = false
|
||||
return
|
||||
}
|
||||
}
|
||||
return saveSettings({ revokeddeniessharedkey: turningOn })
|
||||
}
|
||||
|
||||
function onPendingCapChange() {
|
||||
const wanted = Math.trunc(Number(pendingCapInput.value))
|
||||
if (!Number.isFinite(wanted) || wanted < 0) {
|
||||
|
||||
@@ -4,16 +4,25 @@
|
||||
One row per asset. Holds only the sha256 of the secret the
|
||||
agent was issued, plus a short display prefix. Revoking sets
|
||||
revokedat; re-issuing after a revoke UPDATES the same row, so
|
||||
an asset never carries two credentials. reenrollblocked keeps
|
||||
an asset never carries two credentials. The row therefore also
|
||||
says that this PC has enrolled at least once, which is how
|
||||
agentauth tells a revoked PC from one that never enrolled:
|
||||
plugin.endpoints.revokeddeniessharedkey refuses the shared key
|
||||
to the first and never to the second. reenrollblocked keeps
|
||||
a revoked PC from enrolling again (rolling it back to an agent
|
||||
that only knows the shared key, or a compromised PC) until an
|
||||
admin allows it.
|
||||
|
||||
EndpointEnrollRequest
|
||||
One row per (hostname, serial number) that asked to enroll. The
|
||||
agent's enrollment nonce is recorded on first sight and is what
|
||||
an admin approval is bound to, so a second machine claiming the
|
||||
same hostname and serial cannot collect the approved credential.
|
||||
One row per (hostname, serial number) that asked to enroll -
|
||||
the endpoint's identity, and the reason every contact from one
|
||||
PC lands on one row. The agent's enrollment nonce is recorded
|
||||
on first sight and is what an admin approval is bound to, so a
|
||||
second machine claiming the same hostname and serial cannot
|
||||
collect the approved credential. A REJECTED row binds to the
|
||||
identity instead: any nonce from that hostname and serial stays
|
||||
rejected, so a reinstall cannot argue its way back into the
|
||||
queue. Only an admin approving the request clears it.
|
||||
|
||||
EndpointMigrationWave
|
||||
An admin action that pushed the agent upgrade to a set of PCs.
|
||||
|
||||
@@ -287,6 +287,15 @@ class EndpointsPlugin(BasePlugin):
|
||||
making revocation an instant kill switch. `agentkeyprovisioned` records
|
||||
that a UI-managed key has been minted so the settings page can render
|
||||
the rotate/revoke state instead of the first-run generate state.
|
||||
|
||||
Every key an enforcement path reads belongs here, even when its default
|
||||
is what the code already does with no row at all. Undeclared, the row
|
||||
is only born when somebody writes it and the settings API has to guess
|
||||
its type - which is how bootstrapquerytoken became a string row that
|
||||
its boolean reader could never see turned off. Declared, the row is
|
||||
seeded at install and enable (and by `flask plugin upgrade-all` on a
|
||||
site that installed an earlier version), so the settings pages have
|
||||
something to render and an operator has something to click.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
@@ -318,6 +327,41 @@ class EndpointsPlugin(BasePlugin):
|
||||
'approval when the hostname and serial number '
|
||||
'match exactly one computer.',
|
||||
},
|
||||
{
|
||||
'key': 'plugin.endpoints.revokeddeniessharedkey',
|
||||
'value': 'false',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'plugin.endpoints',
|
||||
'description': 'Also refuse the shared fleet key to a PC whose '
|
||||
'per-PC credential was revoked. Off (the '
|
||||
'default) is what the fleet does today: a '
|
||||
'revoked PC falls back to the shared key, so a '
|
||||
'credential rotation and the documented revoke '
|
||||
'plus blockreenroll rollback to an older agent '
|
||||
'both keep reporting. Turning it on makes a '
|
||||
'revoke an eviction - that PC reports nothing '
|
||||
'and receives no commands until it enrolls '
|
||||
'again - and it stops that rollback working. '
|
||||
'Safe only once every PC holds its own '
|
||||
'credential.',
|
||||
},
|
||||
{
|
||||
'key': 'plugin.endpoints.bootstrapquerytoken',
|
||||
'value': 'true',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'plugin.endpoints',
|
||||
'description': 'Accept a one-line install bootstrap token in '
|
||||
'the ?t= query string as well as the '
|
||||
'X-Endpoint-Bootstrap-Token header. On (the '
|
||||
'default) '
|
||||
'keeps the pasted install commands and the '
|
||||
'Download .ps1 link working; the query form is '
|
||||
'deprecated because the token lands in IIS and '
|
||||
'proxy logs. Turn it off only after every '
|
||||
'outstanding install command has been re-issued '
|
||||
'- an in-flight one stops working the moment it '
|
||||
'is off.',
|
||||
},
|
||||
{
|
||||
'key': 'plugin.endpoints.agentkeyprovisioned',
|
||||
'value': 'false',
|
||||
|
||||
Reference in New Issue
Block a user